Rules & Reference
Validation rules, error codes, edge cases, decision trees. The lookup reference when something breaks.
This is Part 3 of the integration guide.
- Part 1 (REQUIRED): Core Integration. Core storefront: products, cart, checkout, payment, orders
- Part 2 (OPTIONAL): Optional Features. Customer accounts, social login, promotions, upsells, downloads
- Part 3 (REFERENCE): This file. Validation rules, error codes, edge cases, decision trees, common mistakes
Read this file when:
- You get an API error you don't understand
- You need to handle edge cases (expiration, rate limiting, multi-tab)
- You want to verify you're handling all error codes
- You need the decision tree for a conditional flow
- You want to check your work against the common mistakes list
Validation rules
Custom-field filters (getProducts({ metafields }))
| Rule | Server behavior |
|---|---|
Definition must have filterable: true | Otherwise the key is silently ignored |
Definition type must be SELECT, MULTI_SELECT, or BOOLEAN | Other types are silently ignored |
| Unknown definition keys | Silently ignored |
Setting filterable: true via the admin API on a non-SELECT/MULTI_SELECT/BOOLEAN type | HTTP 400: "filterable is only supported for…" |
Filter values for BOOLEAN | Send the strings "true" / "false" |
| Semantics | AND across keys, OR within a key |
Facet value counts: GET /metafield-filters (SDK getMetafieldFilters())
returns one entry per filterable definition with distinct-active-product counts
per value. MULTI_SELECT arrays are split per element, BOOLEAN buckets are
"true" / "false", and zero-count enumValues entries are included. See
Part 1, Step 2.4a.
Cart
| Field | Rule | Error if violated |
|---|---|---|
quantity (add to cart) | Must be integer >= 1 | HTTP 400 |
quantity (update) | Must be integer >= 0 (0 removes item) | HTTP 400 |
variantId | Required for VARIABLE products, must NOT be sent for SIMPLE | HTTP 400: "Variant selection is required for this product" |
productId | Must exist and be published to this connection (404 PRODUCT_NOT_FOUND; an unknown variantId is 404 VARIANT_NOT_FOUND) | HTTP 404 |
metadata.<field> | If product has customizationFields, keys match field.key; shape validated per type (see below) | HTTP 400: "Invalid customization input: ..." |
recommendationToken | Optional. The recToken of the recommendation card the add came from; see Recommendations | An invalid or mismatched token is ignored, not an error |
Cart writes need proof of ownership, and a cart 404 has four different meanings. A write to a guest cart carries that cart's sessionToken as the cartToken query parameter (the SDK adds it; the raw REST helper in Core, Method B does too), or a customer's Bearer for a customer cart. A missing or wrong proof is 403 CART_ACCESS_DENIED on a store that enforces it: never a 401, so do not log the shopper out. Cart 404s say what was missing in code: CART_NOT_FOUND (the cart: re-resolve it), CART_ITEM_NOT_FOUND (the line: re-read the cart), PRODUCT_NOT_FOUND and VARIANT_NOT_FOUND (the product or choice: the cart is fine). Details and what the SDK does for each: Core, Cart ownership proof and Step 3.3b.
Recommendations
| Rule | Why |
|---|---|
crossSells, upsells, related (and the cart's recommendations) are the merchant's manual lists only. Never title crossSells "Frequently bought together" | It is a hand-made list, not purchase data. An old storefront keeps the same fields and must not get a wrong heading |
automatic (product) and automaticRecommendations (cart) are ONE group of one kind: bought_together or similar. Render it as its own section after the manual ones, never merged into them | A group is never mixed, so the heading is always true. "Frequently bought together" appears only for a bought_together group |
| Hide a section with no items. Do not build the automatic group yourself when the field is absent | It is absent on purpose: not enough purchase history, a hidden pair, the merchant's switch off, or a ?type= filter |
Never addToCart({ productId }) a card with requiresVariantSelection: true. Link to its product page. A card with targetVariantId is added with that variantId | A variable product without a variant is refused (400 Variant selection is required) |
Pass recommendationToken: card.recToken only on an add that starts on a recommendation card. Never build, store or reuse a token | The server credits just the units added in that call. A token belongs to one card on one page and expires |
Report an impression only for a card that was on screen (client.observeRecommendation), a click from its link (client.trackRecommendationClick). Do not send events when you fetch | A fetch is not a view. An impression is a card shown on a page load, not a unique visitor |
POST /recommendation-events takes at most 50 events, each with a unique eventId. Retry with the same eventId; do not retry a 4xx | A retry is counted once. A 4xx means the token was refused |
Needs brainerce 3.17.0 or newer for automatic, automaticRecommendations, source, recToken, recommendationToken, observeRecommendation, trackRecommendationClick and trackRecommendationEvents. On an older SDK those fields and methods do not exist: check the installed version first and do not invent them.
How the automatic group is built, so you do not promise more than it does: only paid orders count (not test orders, not cancelled, not fully refunded; a partial refund still counts), over the last 12 months, recomputed daily. A store needs a minimum number of counted orders before any bought_together group exists, and a pair needs a minimum number of shared orders. Imported orders count only for lines that map to a product that exists in the store. The merchant can hide one suggestion (a hidden pair stays hidden after every recalculation) and can turn "Frequently Bought Together" off for a sales channel. There is no category-level exclusion and no separate switch for similar.
Kits (type: 'KIT')
A kit is one purchasable product assembled from other catalog products. It is bought as a SINGLE cart line.
- Add the kit, never its components.
addToCarttakes the kit's ownproductId. The server reserves each component behind that one line. Adding components individually charges the wrong total and double-reserves stock. - Read
kitAvailablefor a kit's stock, notinventory.nullmeans unlimited,0means not sellable. Its stock is whichever component runs out first, so it can drop without the kit itself changing. A kit does carry aninventoryblock, but only the sales-channel (vc_*) reads derive it from the components; astoreIdstorefront gets a placeholder (trackingMode: "TRACKED",available: 0,canPurchase: false) that reads as sold out whatever the components hold.kitAvailableis right in both modes. kitComponentsis display only. Every sales-channel read (list, by slug, by id) returns it, and so do thestoreIdsingle-product reads; only thestoreIdlist omits it. Render it so the shopper can see what is in the box.- Outside
FIXEDpricing,basePriceon the raw product row is a placeholder. The by-slug read returns the resolved price; use that. Checkout recomputes it authoritatively and snapshots the result onto the cart line. - A kit cannot contain another kit, and a component that has variants must have one pinned.
Customization fields (buyer input on product page)
When a product returns customizationFields, any values the buyer fills must be sent on POST /cart/:id/items as metadata. Server-enforced rules per type:
| Type | Expected value shape | Extra rules |
|---|---|---|
TEXT | string | Respects minLength / maxLength (characters) |
TEXTAREA | string | Respects minLength / maxLength (characters) |
NUMBER | number | Respects minValue / maxValue |
BOOLEAN | boolean | None |
DATE | YYYY-MM-DD string | Exactly this shape; must satisfy dateAvailability if set (see below) |
DATETIME | ISO 8601 date-time | YYYY-MM-DDTHH:mm[:ss[.sss]], optional Z/±HH:mm; must satisfy dateAvailability |
URL | string | Must match http(s)://… |
COLOR | string | Must match #RRGGBB |
JSON | string | Must parse via JSON.parse |
SELECT | string | Must be one of enumValues |
MULTI_SELECT | string[] | Every element in enumValues; duplicates removed; minLength/maxLength = array size |
IMAGE | string (asset URL) | Must be the URL returned from POST /customization-upload on this store |
GALLERY | string[] (asset URLs) | Every element must be an upload URL on this store |
Fields marked required: true must be present and non-empty. Unknown keys (not declared on the product) are accepted silently but not snapshotted onto the order line, so only declared fields survive checkout.
dateAvailability (DATE/DATETIME only). A field may restrict which dates/times are valid: minDate/maxDate, blockedWeekdays (0=Sun..6=Sat), blockedDates, the relative bounds leadTimeMinutes / cutoffTime / maxDaysAhead, and, for DATETIME only, businessHours (per-weekday open/close, "HH:mm") + slotDurationMinutes. The server rejects any submitted value outside these bounds with HTTP 400, evaluated in the store's timezone (getStoreInfo().timezone), not the buyer's. Use computeAvailableSlots() / getBusinessHoursForDate() / isDateValueAllowed() (exported from brainerce) to pre-validate client-side and avoid a round trip.
The relative bounds are checkout-only. leadTimeMinutes, cutoffTime and maxDaysAhead are accepted on checkout custom fields and rejected on product metafields and order custom fields, because those two are written by an admin, often long after the order exists, and there is no ordering moment for a lead time to be measured from. minDate, maxDate, blockedWeekdays and blockedDates remain available on all three.
The relative bounds move on their own; minDate/maxDate do not. leadTimeMinutes puts the floor at now + leadTime, so it expresses preparation time. cutoffTime ("HH:mm", store-local) pushes that floor on by a further day once the store clock reaches it, which is how "order by 14:00 for tomorrow" is written. maxDaysAhead is a rolling ceiling counted from today. All three are re-resolved on every request, so unlike an absolute minDate they never go stale, and all three apply to DATE fields just as much as to DATETIME ones. Both may be set at once: whichever floor falls later, and whichever ceiling falls earlier, is the one that applies.
Client-side, the relative bounds need a clock or they are skipped. isCalendarDateAllowed(), computeAvailableSlots() and getBusinessHoursForDate() take an optional last argument { timezone, now? }; isDateValueAllowed() takes an optional 5th argument now. Leave the clock out and only the absolute rules are applied, so your picker offers more dates than the server will accept, never fewer. The server always supplies one, so this is a UX gap and never a way in.
A weekday may carry several businessHours windows. A split day, 09:00 to 13:00 and again 16:00 to 20:00, is two entries sharing one weekday. Two windows on the same weekday must not overlap; an overlap is rejected when the definition is saved. computeAvailableSlots() returns the slots of every window on that day in chronological order regardless of the order they were authored in, and a submitted value must land inside one of them.
A weekday with no businessHours window is closed all day. Once businessHours contains even one entry, every weekday it doesn't mention is rejected with no business hours are configured for this day, because the array is an allowlist, not a set of exceptions. getBusinessHoursForDate() returns [] for those days; use it to grey them out in the picker.
computeAvailableSlots() empty ≠ day closed. It returns [] whenever slotDurationMinutes is unset, even on a fully open day. Call getBusinessHoursForDate() to tell the two apart: windows present + no slots means "render a free time input bounded by open/close" (the server accepts any time in the half-open interval [open, close)); no windows means the day really is closed. On the first bookable day a leadTimeMinutes narrows that interval further, and getBusinessHoursForDate() deliberately does not clamp the window it returns, so bound your input by the window and then call isDateValueAllowed() before you submit.
DATE/DATETIME value format
Parsing is strict. new Date()'s permissiveness is deliberately not the contract, because a value that parses into the wrong instant is worse than one that's rejected.
| You send | Result |
|---|---|
2026-08-13 | ✅ DATE: that calendar day. DATETIME: midnight, store-local |
2026-08-13T13:00 | ✅ 13:00 in the store's timezone. No offset means store-local, not UTC |
2026-08-13T13:00:00+03:00 | ✅ that exact instant |
2026-08-13T13:00:00Z | ✅ that exact instant |
2026-08-13T13:00:00.123456Z | ✅ 1 to 9 fractional digits accepted (Python/Java/Go output), truncated to ms |
2026-08-13T13:00-14:00 | ❌ 400: -14:00 is not a real UTC offset. A slot label is not an offset |
2026-08-13T9:30 | ❌ 400: the hour must be two digits |
2026-02-30 | ❌ 400: not a real calendar date |
Building a value as `${date}T${slotLabel}` is the mistake this table exists for: "2026-08-13" + "T13:00-14:00" used to parse as a legal instant 14 hours away and book the wrong day silently. Send the date and the time as one ISO-8601 value, or send YYYY-MM-DDTHH:mm and let the store's timezone apply.
What comes back is normalized, not what you sent. The stored and returned value is YYYY-MM-DD for DATE and an ISO-8601 UTC instant for DATETIME, regardless of which accepted form you submitted. Don't round-trip the raw string and expect it back verbatim.
Apply-to-all fields. If a field's underlying MetafieldDefinition has appliesToAllProducts: true, the backend folds it into every product's customizationFields array, including products created after the flag was set. Validation treats those entries identically to per-product assignments. Your client code reads product.customizationFields as-is and never needs to merge definitions manually.
Order snapshot. After checkout, each order.items[i].customizations captures { label, value, type } per submitted field. The snapshot is permanent, so editing or deleting the definition afterwards does not change existing orders.
Customization uploads
| Rule | Value |
|---|---|
| Endpoint | POST /customization-upload?fieldKey=<field key> (multipart file field). fieldKey is optional but needed for the merchant's print-quality setting to apply |
| Max size | 10 MB and 40 megapixels |
| MIME allowed | image/jpeg, image/png, image/webp, image/gif only, and the bytes must match the declared type. Anything else (SVG, HEIC, BMP, a PDF renamed .jpg) → HTTP 400 |
| Throttle | 10 uploads / minute per IP → HTTP 429 Too Many Requests |
| Quality | Per field, set by the merchant (keepOriginalImage on the field definition). Off (default): a regular image, converted to WebP and at most 2048 px. On: kept at the pixel size and format you sent (JPEG stays JPEG, PNG stays PNG) so the merchant can print it. No fieldKey, or a key that matches no active customer-input IMAGE/GALLERY field: a regular image, not an error. Malformed fieldKey → HTTP 400. EXIF is removed (including GPS) and its rotation applied either way |
| Retention | At least 7 days. Deleted after 7 days if the cart isn't ordered. |
| Response | { url: string, key: string } on a storeId storefront; the sales-channel route adds width and height (number | null). No size or mimeType is returned |
Checkout
| Field | Rule | Error if violated |
|---|---|---|
email | Required, must be valid email format | HTTP 400 |
firstName | Required for shipping/billing address | HTTP 400 |
lastName | Required for shipping/billing address | HTTP 400 |
line1 | Required for shipping/billing address | HTTP 400 |
city | Required for shipping/billing address | HTTP 400 |
postalCode | Required for shipping/billing address | HTTP 400 |
country | Required, must be 2-letter ISO code (e.g., "IL", "US") | HTTP 400 |
phone | Optional but recommended | None |
notes | Optional order note, max 2000 chars. Include an "Order notes" textarea by default | HTTP 400 if > 2000 chars |
Input attributes (not validated by the server, but required of every checkout form). Each input carries an autocomplete token, a name, and for email and phone an inputmode, so a phone can autofill the saved address and show the right keyboard:
| Field | autocomplete | Also |
|---|---|---|
email | email | type="email", inputmode="email" |
firstName | given-name | |
lastName | family-name | |
phone | tel | type="tel", inputmode="tel" |
line1 | address-line1 (off on a typeahead field) | |
line2 | address-line2 | |
city | address-level2 | |
region | address-level1 | |
postalCode | postal-code | no numeric inputmode: letters occur |
country | country |
Prefix the tokens with shipping or billing when one page holds both address blocks. See Core, Checkout input attributes.
Gift cards
| Field | Rule | Error if violated |
|---|---|---|
code (apply / balance) | Required string, 4-64 characters. Case, spaces and dashes are all tolerated, and the letters excluded from the code alphabet (I, L, O, U) are corrected to the characters they are mistaken for, so you do not need to normalize the shopper's typing | HTTP 400 |
tenderId (remove) | The tenderId from checkout.tenders, never the code. A checkout can carry several cards | HTTP 404 if it is not a live tender on this checkout |
| Checkout state | Apply and remove only work while the checkout is still editable | HTTP 400 CHECKOUT_LOCKED once payment is in progress |
| Currency | A card pays only in its own currency. There is no conversion | Refused like any other unusable code |
A gift card is a means of payment, not a discount. Applying one does not
change checkout.total, and tax stays calculated on the full order value. What
drops is checkout.providerAmountDue — what the payment provider will be
charged. Render the card on its own line below the total, then an "Amount
due" line; never inside the discount block and never added to discountAmount.
Folding stored value into a discount understates the taxable base to the shopper
and on their receipt.
Customer
| Field | Rule | Error if violated |
|---|---|---|
email | Required, valid email, unique per store | HTTP 400: "Email already exists" |
password | Required for registration. At least 8 characters, with at least one lowercase letter, one uppercase letter, one digit, and one special character | HTTP 400: "Password must contain at least 1 uppercase letter, 1 lowercase letter, 1 number, and 1 special character" |
firstName | Optional | None |
lastName | Optional | None |
Password strength (registration AND reset)
The same rule is enforced on registerCustomer() and on resetPassword(). The server-side pattern is:
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z0-9]).{8,}$/securepassword123 and newSecurePassword123 both fail: the first has no uppercase and no special character, the second has no special character. SecurePass123! passes. A form that only advertises "min 8 characters" produces a 400 the shopper cannot explain, so mirror the full rule in your own client-side validation and in the field's helper text, and render the server's message verbatim when it comes back.
Error codes
Every error body carries a code. Some endpoints set a specific code you can switch on; the rest fall back to a generic code derived from the HTTP status. Knowing which is which is the difference between a working error branch and a dead one.
Every error response has this shape:
{
"statusCode": 400,
"code": "INSUFFICIENT_STOCK",
"message": "Only 2 items available",
"timestamp": "2026-08-23T10:04:11.512Z",
"path": "/api/stores/store_xyz/cart/cart_abc/items"
}When no specific code was set, code is the status fallback: BAD_REQUEST (400), UNAUTHORIZED (401), FORBIDDEN (403), NOT_FOUND (404), CONFLICT (409), UNPROCESSABLE_ENTITY (422), RATE_LIMITED (429), PAYLOAD_TOO_LARGE (413), UNSUPPORTED_MEDIA_TYPE (415), INTERNAL_SERVER_ERROR (5xx) and ERROR for any other status. The full list of specific codes is in Errors. A generic code is not a bug. It means that call site distinguishes cases by message, not by code.
The envelope has a fixed key set, so structured context rides in details
The global error filter rebuilds every error body from a fixed set of keys:
statusCode, code, message, error, details, plus timestamp and path.
Any other key an endpoint attaches is dropped before the response is sent, with one exception: CHECKOUT_TOTAL_CHANGED, TAX_ADDRESS_REGION_REQUIRED and TAX_NO_RATE_FOR_DESTINATION also carry a small flat set of figures at the top level (their fields are listed under each code in Errors).
That is exactly why every error's structured context (the offending line
items, the available and requested quantities, the list of invalid modifier
selections) arrives inside details, never as a top-level key. details
is part of the envelope, so it survives. When this page says an error
"carries items[]", it means details.items.
Where the fields land in the SDK. BrainerceError has exactly three own
properties: message, statusCode, and details, and details is the whole
parsed error body. So the response's code is err.details.code. From SDK
3.11.0 there is also a read-only err.code getter that returns exactly that
(undefined when the failure had no response body: offline, timeout); on an
older SDK read err.details?.code. The body's own details block is therefore
at err.details.details. Yes, details twice, and that is not a typo. Over raw
HTTP there is only one level: body.details.
import { BrainerceError } from 'brainerce';
import type { ModifierValidationFailedError } from 'brainerce';
try {
await client.smartAddToCart({ productId, quantity: 1, selections });
} catch (err) {
if (!(err instanceof BrainerceError)) throw err;
// err.details IS the response body.
const body = err.details as ModifierValidationFailedError | undefined;
if (body?.code === 'MODIFIER_VALIDATION_FAILED') {
// …and body.details is the body's own `details` block.
body.details.errors.forEach(showInlineNextToGroup);
return;
}
showGenericMessage(err.message);
}
// ⛔ None of these exists on BrainerceError (err.code does, from SDK 3.11.0, as a
// getter for err.details.code)
err.items;
err.details.errors; // ← one level too shallow; it is err.details.details.errorsSwitch on err.details?.code and render a message you control. Still guard
details with ?., because errors that have no structured context omit the
block entirely.
Cart errors
These carry a specific code. Switch on it:
| Error code | Message | What to do |
|---|---|---|
PRODUCT_UNAVAILABLE | "This product is currently unavailable for purchase" | Remove item from UI, show "Product unavailable" |
INSUFFICIENT_STOCK | "Only {available} items available" | Read details.available and details.requested, then clamp the quantity input to details.available. Never parse the number out of message |
COUPON_PER_CUSTOMER_LIMIT_REACHED | "You have already used this coupon the maximum number of times." | Show "You have already used this coupon the maximum number of times" |
MODIFIER_VALIDATION_FAILED | "One or more selected options are not valid for this product" | See Modifier validation errors below |
CART_NOT_FOUND (404) | "Cart not found" | The cart is gone or no longer ACTIVE. Re-resolve the cart (the SDK does, once); start a new one only if that also 404s |
CART_ITEM_NOT_FOUND (404) | "Cart item not found" | The line is already gone. Show the current cart |
PRODUCT_NOT_FOUND (404) | "Product not found" | The product is unknown or not visible (also raised by add to cart). The cart is fine: show "unavailable", keep the cart |
VARIANT_NOT_FOUND (404) | "Variant not found" | The product has no such variant. The cart is fine |
CART_ACCESS_DENIED (403) | "This cart can only be changed by the shopper who holds it. ..." | The guest cart's proof is missing or wrong, on a store that enforces it. Send cartToken (see Core). A 403, never a 401: do not log the shopper out. Do not retry unchanged |
Coupon rejections carry a code: branch on code, translate the message yourself
Every applyCoupon refusal is an HTTP 400 with a stable code in error.details.code. The message is English prose that the platform may reword, so build the decision on the code and show your own translated text.
code | What happened | What to show |
|---|---|---|
COUPON_NOT_FOUND | No coupon with that code, or it is not published to this channel | "That code isn't valid". Do not reveal that it exists |
COUPON_NOT_ACTIVE | startsAt is in the future | "This coupon isn't active yet" |
COUPON_EXPIRED | endsAt has passed | "This coupon has expired" |
COUPON_USAGE_LIMIT_REACHED | Store-wide usageLimit exhausted | "This coupon is no longer available" |
COUPON_PER_CUSTOMER_LIMIT_REACHED | This customer already used it the allowed number of times | "You have already used this coupon" |
COUPON_MINIMUM_NOT_MET | Cart subtotal is below the coupon minimum (the message names the amount) | Show the required amount, keep the code in the input |
COUPON_REGION_RESTRICTED | The coupon is not valid in the shopper's region | "This coupon is not available in your region" |
COUPON_NO_ELIGIBLE_ITEMS | No item in the cart qualifies | "This coupon does not apply to any items in your cart" |
Two refusals still carry no specific code and arrive as the generic BAD_REQUEST, so match their message and keep a default branch:
message | What happened | What to show |
|---|---|---|
Coupon can only be applied to an active checkout | Checkout is past the editable stage; payment started | Hide the coupon input once payment begins |
Coupons cannot be applied to partial checkouts | Checkout was created with selectedItemIds | Hide the coupon input for partial checkouts |
Modifier validation errors
When a cart add/update payload carries selections and the server rejects it, the
server throws this structured envelope:
{
"statusCode": 400,
"code": "MODIFIER_VALIDATION_FAILED",
"message": "One or more selected options are not valid for this product",
"details": {
"errors": [
{
"code": "REQUIRED_GROUP_MISSING",
"message": "...",
"groupId": "mg_bread",
"meta": { "groupId": "mg_bread" }
}
]
},
"timestamp": "2026-08-23T10:04:11.512Z",
"path": "/api/stores/store_xyz/cart/cart_abc/items"
}The issue list is at
details.errors, not at the top level. The error filter rebuilds the body from a fixed key set, so anything outside the envelope would be dropped.detailsis the slot that survives.
messageis a generic, non-specific sentence by design: it describes the class of failure, not which option was wrong. Branch oncodeand render your own copy fromdetails.errors. That array is where the actionable per-group detail lives.
Switch on err.details?.code === 'MODIFIER_VALIDATION_FAILED' (over raw HTTP, the body's own code), then iterate the issue list (err.details?.details?.errors ?? [] from the SDK, or body.details.errors over raw HTTP) and surface each one inline next to the relevant group / modifier. The codes below are the full set the validator can raise.
errors[i].code | When it fires | What to do |
|---|---|---|
REQUIRED_GROUP_MISSING | A required: true group has no selection | Highlight the group, show "Please pick at least one" |
MIN_SELECTIONS_NOT_MET | Fewer picks than min (meta: min, current) | Show "Pick at least N" |
MAX_SELECTIONS_EXCEEDED | More picks than max (meta: max, current) | Show "Pick at most N", and disable extra checkboxes client-side to skip the round-trip |
SINGLE_GROUP_MULTIPLE_SELECTIONS | More than one modifier sent for a SINGLE group (meta: current) | Bug in your renderer. Re-render as radio |
MODIFIER_NOT_IN_GROUP | A modifierId does not belong to the named group | Refetch the product and re-render; the catalog moved under you |
UNKNOWN_GROUP_IN_SELECTION | A modifierGroupId is not attached to this product / variant | Same again: refetch the product |
MODIFIER_NOT_ACTIVE | The picked modifier has been archived by the merchant | Refetch the product; the option should be gone |
MODIFIER_UNAVAILABLE | The picked modifier is sold out right now (available: false) | Refetch the product, ask the customer to re-pick |
MODIFIER_DISABLED_FOR_VARIANT | The picked modifier is disabled for the chosen variant | Refetch the product for this variant; the option should be absent |
MODIFIER_NESTING_TOO_DEEP | A nested combo goes deeper than 3 levels, counting the product itself as level 1 (meta: depth, max). Cart add and update refuse a 4th level earlier, with a plain 400 and the message nested structure exceeds maximum depth of 3, so you normally see that message instead of this code | Bug in your renderer. Never go past 3 levels |
Each entry of details.errors is { code, message, groupId?, modifierId?, meta? }. The group id field is named groupId here, not modifierGroupId (the name used in the request). message is an English fallback; localise by code.
MODIFIER_PRICE_FLOOR_VIOLATED is an entry of details.errors, the only one that comes from pricing instead of the validator. When the unit price after applying downsells would go below 0, the response is the same 400 envelope as above (code: "MODIFIER_VALIDATION_FAILED"), and details.errors holds a single entry { "code": "MODIFIER_PRICE_FLOOR_VIOLATED", "message": "The combination of selections exceeds the base price discount limit. Please adjust your choices." } with no groupId. The top-level code is not MODIFIER_PRICE_FLOOR_VIOLATED: look for that code inside details.errors. Show "Cannot apply more discounts on this item" and let the customer remove a downsell.
Generic 400, no internals leak. The price-floor refusal deliberately reports a generic message; the merchant's pricing internals (which downsell pushed it negative, by how much) never leak to the storefront. Show a friendly user-facing message, not the raw envelope.
Disable-for-variant convention. A group with effectiveMax === 0 is silently skipped by the validator, and never fires REQUIRED_GROUP_MISSING or MAX_SELECTIONS_EXCEEDED. The storefront should match: if product.modifierGroups[i].max === 0, do not render and do not include the group in selections.
Checkout errors
Checkout reuses the same codes as the cart, and there are no *_CHECKOUT-suffixed
variants (INSUFFICIENT_STOCK_CHECKOUT, PRODUCT_UNAVAILABLE_CHECKOUT, EMPTY_CART,
CHECKOUT_EXPIRED, SHIPPING_ADDRESS_REQUIRED, CUSTOMER_EMAIL_REQUIRED and
SHIPPING_RATE_NOT_FOUND are not emitted by anything). These three are the specific
codes checkout does set:
| Error code | Message | Carries | What to do |
|---|---|---|---|
INSUFFICIENT_STOCK | "Some items are out of stock" | details.items[] with productId, variantId, available, requested | Mark exactly those lines in the cart UI and clamp each to its available. No re-fetch needed |
PRODUCT_UNAVAILABLE | "Some products are no longer available for purchase" | details.items[] with productId, variantId | Remove exactly those lines and tell the shopper which ones went away |
PRICE_DRIFT | "Prices in your cart have changed since you added items." | details.items[] with itemId, oldUnitPrice, newUnitPrice, delta, direction | Show a "prices changed" dialog listing the old and new prices, then either POST /cart/{id}/refresh-snapshots to accept them or remove the affected lines, and retry |
INSUFFICIENT_STOCK can also come from createPaymentIntent(), not only from createCheckout().
On a sales-channel storefront whose channel holds no stock before payment (reservationStrategy
ON_PAYMENT, the default), the payment step re-checks every line right before the charge, because
stock can move while the buyer fills in the form. It answers the same 400 with the same
details.items[], and nothing is charged. available is what this channel may sell (the shared
pool minus the units reserved for other channels). Handle it on both calls; startPayment() reports
it to onError as e.code === 'unavailable' with the original BrainerceError in e.cause
(e.cause.code === 'INSUFFICIENT_STOCK').
A shipping rate with requiresPickupPoint: true adds two more, both HTTP 400:
| Error code | When | What to do |
|---|---|---|
PICKUP_POINT_REQUIRED | createPaymentIntent or completeCheckout while that rate is selected and no point is chosen | Send the shopper to the pickup point step (Step 4.6b). Keep the pay button disabled until checkout.pickupPoint is set |
PICKUP_POINT_NOT_APPLICABLE | selectPickupPoint while the selected rate does not deliver to a pickup point | Select the pickup point rate first |
createPaymentIntent has one more: HTTP 409 CHECKOUT_OUT_OF_SYNC — the cart changed
(an order bump accepted on the payment step, a line removed elsewhere) after the checkout
was created, so its subtotal / total no longer match its lineItems. Re-fetch the
checkout (GET /checkout/{id}), which re-derives the totals from the lines, show the
shopper the new total, then create the payment intent again. Never charge a total you
have not just re-read.
completeCheckout refuses the same situation with HTTP 400 CHECKOUT_CART_CHANGED: the
cart lines no longer add up to the checkout's quoted subtotal, so no order is created.
Re-fetch the checkout, show the new total, then complete it again.
createPaymentIntent answers three more, none of them the buyer's fault. HTTP 400
invalid_expected_total: expectedTotal was not a finite number from 0 to 1e12 (major units).
HTTP 400 presentment_currency_unsupported (with capability: 'dcc-to-base' | 'unsupported'):
the checkout is priced in a presentment currency that the chosen provider cannot settle, so
nothing was charged and the buyer has to continue in the store's base currency. HTTP 409
checkout_claim_failed: another request is already creating the payment for this checkout, or
it is no longer payable; read the checkout and continue from its status instead of retrying.
Cart changes are refused while a payment for that cart is in flight: HTTP 409
CART_LOCKED_FOR_PAYMENT on add, update, remove, clear, coupon, refresh-snapshots and
merge. It applies while a payment intent is being created, and once Brainerce has recorded the
payment as authorized or captured, until the order exists (a few seconds). It does not apply while
the shopper is only looking at the payment form, so accepting an order bump on the payment
step still works. The cart unlocks by itself after 30 minutes without activity or when the
checkout expires. Tell the shopper their payment is being finished; never retry in a loop.
Payment is also checked against the cart at the moment it settles: if the cart no longer
matches what was quoted, no order is created and the payment is flagged for a refund.
Send expectedTotal with every createPaymentIntent (and startPayment). Tax is
re-verified right before the charge, because a tax rate or a discount can change while the
buyer is paying. Pass the checkout.total you are showing (expectedTotal: Number(checkout.total)).
On HTTP 409 CHECKOUT_TOTAL_CHANGED (isCheckoutTotalChangedError(err)) nothing was
charged: show the buyer err.details.newTotal (and newTax), ask them to confirm, then
retry with expectedTotal = newTotal. Never retry silently with the new total, and never
compute expectedTotal yourself. Without it the platform charges the freshly verified
total without asking the buyer. startPayment reports it as onError code total_changed.
Collect region (state or province) for US, CA and AU, and handle HTTP 400
TAX_ADDRESS_REGION_REQUIRED. Those countries tax by state or province, so a shipping
address with no region cannot be taxed completely. createPaymentIntent / startPayment
refuse it BEFORE anything is charged (isTaxAddressRegionRequiredError(err),
err.details.country; startPayment code region_required). Ask for the state or province,
update the shipping address, retry. Countries with only country-wide rates are never blocked.
Handle HTTP 400 TAX_NO_RATE_FOR_DESTINATION. A merchant can choose to refuse orders to a
country with no tax rate. createPaymentIntent (and completeCheckout for an order that needs no
payment) then answer it BEFORE anything is charged, with country at the top level. Show the
message and let the buyer choose another delivery address: the same address will not work. It is
off by default.
The response you actually receive:
{
"statusCode": 400,
"code": "INSUFFICIENT_STOCK",
"message": "Some items are out of stock",
"details": {
"items": [{ "productId": "prod_abc", "variantId": "var_1", "available": 2, "requested": 5 }]
},
"timestamp": "2026-08-23T10:04:11.512Z",
"path": "/api/stores/store_xyz/checkout/chk_abc/complete"
}details.items[] names the offending lines precisely, so you can point at them
directly instead of re-reading the whole cart and re-deriving which line broke.
From the SDK that is err.details.details.items (err.details is the whole
body). Still guard with ?. and keep a "something in your cart changed"
fallback: PRODUCT_UNAVAILABLE can also be raised for a single deleted variant,
and a future call site may omit the block.
The rest of the checkout failures are plain 400s and 404s, so match on message
code is the generic BAD_REQUEST / NOT_FOUND for these, so branch on the HTTP status
and use the message only to choose your wording:
| Status | message | What to do |
|---|---|---|
| 400 | Cannot create checkout from empty cart | Send the shopper back to the cart page |
| 400 | Checkout session has expired | Create a new checkout from the same cart; the cart is still valid |
| 400 | Shipping address must be set first | Show the shipping-address form; you called a later step out of order |
| 400 | Customer email is required | Show the customer-info step first |
| 404 | Shipping rate not found | Re-fetch shipping rates |
| 404 | Shipping rate not found or no longer available for this address | The rate exists but is no longer eligible for this address. Re-fetch rates and make the shopper re-pick |
Payments are paused during a store ownership transfer (503 PAYMENTS_PAUSED)
POST /payment/intent (SDK createPaymentIntent) refuses with HTTP 503 and a
specific code while the store is changing hands. The platform pauses payments the
moment the buyer of a store accepts the transfer, and lifts the pause only when the
new owner connects a payment provider whose connection test passes. Nothing the
storefront does can end it, and it has lasted weeks on a real store.
{
"statusCode": 503,
"code": "PAYMENTS_PAUSED",
"message": "Payments are temporarily unavailable for this store",
"details": { "reason": "ownership_transfer" },
"timestamp": "2026-09-11T10:04:11.512Z",
"path": "/api/stores/store_xyz/payment/intent"
}| Rule | Why |
|---|---|
Show the message as-is | It is the truth: the shop cannot take money right now. Do not translate it into "try again in a moment", because a retry will not help |
| Keep the cart and checkout | The cart is valid and the checkout is not expired. Do not clear either, and do not create a new checkout; the shopper's work is not lost |
| Do not retry in a loop | It is not transient. Poll, back off, or re-create the intent and you get the same 503 until a human acts in the dashboard. One attempt per tap |
Do not branch on reason | The error body carries no reason field on the wire (the envelope drops it). code is the whole signal |
Every charge path is gated the same way (donations included), and the pause is
independent of the provider you asked for: a providerId for PayPal fails exactly
like the default card processor. The one exemption is the sandbox provider on a
TEST channel with sandbox payments enabled, so a test checkout completing is
not evidence that live payments work.
Gift card refusals are all the same answer, on purpose
POST /checkout/{id}/gift-card refuses with a plain HTTP 400, generic
code: "BAD_REQUEST", and one message for every reason:
That gift card code cannot be used on this order.An unknown code, an expired card, a spent one, a disabled one and one in the wrong currency are indistinguishable on the wire. That is deliberate: a gift card code is bearer value, so a response that told them apart would be a free oracle for discovering which codes are real. The specific reason is logged server-side and never returned.
So do not build UI that tries to explain which it was. Show one message —
"We can't use this code" — and let the shopper re-enter it. There is no
GIFT_CARD_EXPIRED, GIFT_CARD_NOT_FOUND or INSUFFICIENT_BALANCE code on the
wire; a switch on those names never fires.
POST /gift-cards/balance behaves the same way and does not error at all: an
unknown, disabled or expired code returns { balance: "0.00", usable: false },
byte-identical to each other and in the same amount of time.
The other two failures on these routes are ordinary and safe to explain:
| Status | message | What happened | What to do |
|---|---|---|---|
| 400 | This order is already fully covered. | Cards on the checkout already cover the total, so there is nothing left for another card to pay | Hide the gift-card field and show the "amount due 0" state |
| 400 | Cannot modify checkout while payment is being processed (code: "CHECKOUT_LOCKED") | Apply / remove after payment started | Hide the gift-card field once you create the payment intent |
| 404 | No such tender on this checkout | The tenderId is not a live tender here — usually a stale one you cached | Re-read the checkout and render checkout.tenders again |
Address autocomplete failures (non-fatal, never block checkout on these)
getAddressSuggestions() / getAddressDetails() (see Core Integration Step 4.5)
can fail for reasons unrelated to the shopper's input: the store hasn't
configured GOOGLE_PLACES_API_KEY yet, a transient upstream error, or a
q under 3 characters / missing sessionToken. Always catch and degrade to
plain manual entry (hide the dropdown, let the shopper keep typing in the
now-plain line1 field). Never show a blocking error or disable the form.
This mirrors how the backend itself treats a Places API failure: it disables
suggestions, it never fails the checkout.
400 "property X should not exist", an unknown field in the body
Every write endpoint validates against a strict allow-list. One property that isn't in the documented payload rejects the whole request:
{
"statusCode": 400,
"code": "BAD_REQUEST",
"error": "Bad Request",
"message": "property lat should not exist, property lng should not exist"
}This does not degrade. The call simply never succeeds, so a field sent on
every submit blocks that step for every shopper. The message names each
offending property; remove them from the body rather than retrying.
The one that bites in practice is the address step, because
getAddressDetails() resolves an address carrying lat, lng and
formattedAddress, none of which any address endpoint accepts, so spreading
it into setShippingAddress() / setBillingAddress() sends all three. The
SDK (≥ 1.53.0) strips exactly those three for you, so the spread is safe
there; over raw HTTP it is not, and you must build the body from the documented
fields. Coordinates are never accepted from the client by design: zone matching
decides which shipping rate is offered and charged, so the server resolves them
itself from placeId. Use address.lat/address.lng for your own UI (a map
pin, a distance readout) and pass placeId.
Edge cases
Checkout expiration
- Checkout expires after 30 minutes from creation
- The
expiresAtfield in the checkout response tells you the exact expiry time - Every checkout operation checks expiration. If expired, it returns HTTP 400 with
message: "Checkout session has expired"and the genericcode: "BAD_REQUEST". It is not a 410, and there is noCHECKOUT_EXPIREDcode, so branch on the message rather than on a code that never arrives. - The expiry check only fires while the checkout is still
PENDING. A checkout that already reached a terminal state does not start returning "expired" once the clock runs out. You get whatever that state's error is instead. - What to do when expired: Create a new checkout from the same cart. The cart is still valid.
- Optional: Show a countdown timer based on
expiresAtso the user knows how much time they have
Rate limiting
Two tiers, both keyed per IP address, not per sales channel and not per API key. Both are evaluated on every request and the most restrictive verdict wins:
| Tier | Default limit | Window |
|---|---|---|
| Short | 60 requests | 1 minute |
| Long | 1000 requests | 1 hour |
Because the key is the client IP, everyone behind one NAT or one corporate proxy shares a budget, and a server-side integration that funnels all traffic through one host burns one budget for every shopper. That is the reason to call the storefront API from the browser rather than proxying it.
Sensitive endpoints are much tighter than 60/min. Registration, login, password reset, coupons, gift cards, reviews, donations, event registrations, guest checkout, payment confirmation and similar routes carry their own limit between 3 and 20 requests a minute per IP; a few read routes are set above 60. The complete per-route table is the single source in Rate limits: storefront routes with their own limit. The ones a storefront meets first: POST /customers/register and POST /customers/login 5 a minute (register is 3 on the storeId surface), POST /customers/forgot-password 3, POST /checkout/{id}/complete and POST /payment/intent 10, POST /cart/{id}/coupon 10, POST /checkout/{id}/gift-card 5. GET /cart/{id} is limited to 30 a minute on the storeId surface and has only the global 60 on vc_*.
A per-route override replaces the short tier only, and the 1000/hour tier still applies on top of it. Never retry a coupon or a login in a tight loop; at 3 to 10 per minute you will lock the shopper out of their own checkout.
GET /cart/{id} is the limit a storefront hits by accident. Every cart write already answers with the whole updated cart, so a storefront that reads the cart again after each add, quantity change or removal spends its per-IP cart-read budget (and a round trip) on data it already holds. Render the response instead of refetching: see Core, Step 3.3b and mistake #23 below. A server-side proxy makes it worse, because every shopper then shares the proxy's one IP.
On 429 the body is:
{
"statusCode": 429,
"code": "RATE_LIMITED",
"message": "Rate limit exceeded — retry in 42 seconds.",
"details": { "retryAfterSeconds": 42, "limit": 60, "ttlMs": 60000 }
}Switch on code === 'RATE_LIMITED' (that one is a real code) and wait details.retryAfterSeconds (also sent as the Retry-After header, in seconds) before retrying. Show "Please wait a moment…" rather than a raw error. From the SDK that reads err.details.code and err.details.details.retryAfterSeconds. Yes, details twice, and that is not a typo: BrainerceError.details is the whole response body, and this body has its own details block. That double-hop is the norm, not an exception, because details is the envelope slot every error uses for structured context.
Headers on every response, success or failure. The short tier is unsuffixed, the long tier carries a -long suffix:
X-RateLimit-Limit/X-RateLimit-Limit-long: the tier's ceilingX-RateLimit-Remaining/X-RateLimit-Remaining-long: requests left in that windowX-RateLimit-Reset/X-RateLimit-Reset-long: seconds until that window resets- The same values also arrive as the standard
RateLimit-Limit,RateLimit-Remaining,RateLimit-ResetandRateLimit-Policy(the bucket closest to exhaustion), andRetry-Afteron a429. See Rate limits.
The SDK already retries once automatically on a 429 that carries Retry-After, waiting the header's value capped at 60 seconds. A second 429 surfaces to you as a BrainerceError, so handle it and don't add your own retry loop on top.
Domain / Origin restrictions
A sales channel has one domain, set in the dashboard under Channels → your channel →
Settings. What it enforces depends on the channel's mode.
LIVE channels
- The request must carry an
Originheader, and it is not treated as "no origin to check". Mismatch is rejected with403 Origin not allowed; a missing header is rejected earlier, by the guard described above, with403 Origin header required. Server-side rendering and Route Handlers send noOriginby default, which is why they fail here — but the fix is to set one, not to reach for an admin key. See Calling from a server below. - The channel must have a
domain. A LIVE channel without one rejects every request with403 Connection missing domain. - The
Originhost must equal thedomain's host, or be a subdomain of it:shop.example.commatchesexample.com. Comparison is case-insensitive, a leadingwww.is ignored on both sides, and the port is significant, sohttp://localhost:5173does not matchhttp://localhost:3000. The scheme is not compared. - An origin that is not on the
domainis also accepted when it equals one of the channel's Additional allowed origins (allowedOrigins, at most 5, set in the same channel settings): an exact match on scheme, host and port, no wildcards. That list never replaces thedomain(a LIVE channel with nodomainis refused even for a listed origin), and on a LIVE channel it does not widen where social-login redirects may go: those land only on thedomain(or a subdomain). See Social login. - Error on mismatch:
403 Origin not allowed.
TEST channels
- With no
domainset, anyOriginis accepted. This is deliberate: during early development the storefront host is unpredictable (localhost, a tunnel, a preview URL). It is also the reason a TEST channel is not a security boundary. Lock it down by setting adomain, or switch to LIVE. - With a
domainset, a request that carries anOriginis matched exactly as LIVE does, so a TEST channel pointed at a real domain cannot be reached from arbitrary origins.
⛔ "Any origin" never means "no origin". The matching rules above are the second of two
layers. Every /api/vc/* route first passes BrowserOriginGuard, which requires the header
to be present at all — in TEST as much as in LIVE, with or without a domain — and answers
403 Origin header required when it is missing, before any channel is even looked up. So a
curl or a server-side fetch that sends no Origin is refused on a wide-open TEST
channel exactly as it is on a locked-down LIVE one. See Calling from a server below for
the supported way to do that.
- TEST mode is not read-only. It is the same API surface as LIVE, against the same store data, governed by the same per-channel scopes.
Calling from a server
Server-side rendering, Route Handlers and background jobs are supported. Pass origin
when you construct the client and the SDK sends it on every request:
const client = new BrainerceClient({
salesChannelId: 'vc_abc123...',
origin: 'https://mysite.com', // must match the channel's `domain` on LIVE
});⛔ Do not reach for an admin brainerce_* key to work around this. An admin key is
full-access and store-wide; putting one in a storefront server to render a product page
trades a scoped, per-channel credential for one that can rewrite the whole store, and it
only takes one logged request or one leaked env var. The origin option is the supported
answer and costs one line.
⛔ Do not hardcode the value. On LIVE it must match the channel's domain, so a
build-time constant sends the wrong host from any deploy whose URL you did not know when
you wrote it — the common case for AI-builder and preview deploys. Derive it from the
incoming request, falling back to a configured site URL. The npx create-brainerce-store
starter does exactly this in src/core/lib/brainerce.server.ts.
During development. Use a TEST channel with no domain. Do not put localhost in the
domain field of a LIVE channel. domain is what LIVE identifies your storefront by, and
pointing it at loopback breaks production traffic.
Disabled channels and suspended stores are refused before any origin check runs:
403 Connection is disabled and 403 Store is suspended (code STORE_SUSPENDED)
respectively, on reads as well as writes.
A Free store at its monthly order cap opens no checkout. createCheckout() answers
402 with code PLAN_ORDERS_EXHAUSTED before the shopper can pay, so nothing is charged. Catch it
on createCheckout() and show "This store is not accepting new orders right now" instead of the
response message (which is written for the merchant). The npx create-brainerce-store starter
does this in its checkout page. Paid plans never return it.
Session and multi-tab behavior
| Scenario | Behavior |
|---|---|
| Same browser, multiple tabs | Tabs share the same cart (same sessionToken in localStorage) |
| Different browsers or devices (guest) | Each has its own cart (different sessionToken) |
| Logged-in customer, any device | All share the same cart (linked to customerId) |
| Guest logs in | Must explicitly merge carts (Task 8, step 8.3). Not automatic. |
| Two tabs complete same checkout | First succeeds, second gets same order (idempotent). No duplicate orders. |
| Two tabs add same item | Both succeed, quantity increases |
Idempotency
These operations are safe to retry:
| Operation | Retry behavior |
|---|---|
completeCheckout | Returns same order on second call (no duplicate) |
addToCart (same item) | Adds quantity to existing (does NOT create duplicate line item) |
applyCoupon (same code) | Returns success if already applied |
| Payment webhook + API complete | Both return same order |
Cart limits
A shopper-facing cart holds at most 50 distinct line items. The 51st addToCart for a
new product fails with 400 and the error code CART_LINE_LIMIT_REACHED:
{
"statusCode": 400,
"code": "CART_LINE_LIMIT_REACHED",
"message": "A cart can hold at most 50 different items. Remove something before adding more.",
"details": { "maxLines": 50 }
}It is a fixed platform limit. There is no per-store setting and it cannot be raised.
Detect it by the code, not by the sentence. With the SDK, the parsed body is on
BrainerceError.details, so the code is one level in:
import { BrainerceError } from 'brainerce';
try {
await client.smartAddToCart({ productId, quantity: 1 });
} catch (err) {
const body =
err instanceof BrainerceError
? (err.details as { code?: string; details?: { maxLines?: number } })
: undefined;
if (body?.code === 'CART_LINE_LIMIT_REACHED') {
showCartFull(body.details?.maxLines ?? 50); // not a generic error toast — see below
}
}details.maxLines carries the limit, so nothing has to parse the number back out of the
message. On the raw HTTP API code and details are siblings at the top level of the body,
as in the response above; only the SDK nests them under err.details.
⛔ If you already match the message text, keep that match as a fallback. The wording is unchanged, deliberately: storefronts built before the code existed still rely on it, and a storefront can be talking to a backend that has not been redeployed yet. Branch on the code first and fall through to the prose match, rather than replacing one with the other.
Which modes it applies to — this is the part that bites, because it is not uniform:
| Mode | Capped? |
|---|---|
storeId (public storefront) | Yes |
salesChannelId: 'vc_*' | Yes |
apiKey: 'brainerce_*' | No |
The admin key is uncapped on purpose: a B2B order or a bulk import legitimately carries
hundreds of SKUs, and that traffic is authenticated rather than anonymous. So a cart you
built over the /v1 API can exceed 50 lines and a storefront cart cannot, and the same
addToCart call can therefore succeed for one and fail for the other.
What the cap does not block. It is checked only when a new product line would be created. A cart already at or over the limit can still have quantities changed, items removed and coupons applied, so it never becomes unusable. Adding more of a product that is already in the cart is not a new line and is never refused by this rule.
Handle the 400 as a cart-full state, not as a failure. Show the shopper "your cart is full, remove something to add this", not a generic error toast, and keep the item they tried to add visible so one tap retries it.
⛔ Adding a bundle can fail halfway. The bundle and order-bump routes add their products one call at a time and are not wrapped in a transaction, so a bundle whose lines would cross 50 throws part-way and leaves the earlier products of that bundle in the cart. Re-read the cart after a failed bundle add and reconcile against what you asked for, rather than assuming the cart is unchanged.
Currency
- A store has one base currency (
store.currency). Every price on a product, cart or checkout is in that currency unless a field explicitly says otherwise. - All prices are strings with 2 decimal places (e.g.,
"150.50","0.00") - Always use
parseFloat(), neverparseInt() - Format using
Intl.NumberFormatwith the currency code that belongs to the amount you are formatting, never a hard-coded one
Regions can put a second currency on the page
"There is no currency conversion" used to be true and no longer is. Two distinct things ship, and confusing them is how a store ends up displaying one number and charging another:
Display-only conversion (product reads). Call getProducts({ regionId }) and each product/variant gains displayPrice, displaySalePrice and displayCurrency, the base price run through the daily FX snapshot. It is what you show. It is never what is charged. Use formatProductPrice() / formatVariantPrice() from the SDK; they prefer the display* fields when present and fall back to base.
FX-at-checkout (presentment). Pass regionId to createCheckout() and, if the region's currency differs from base and the region's payment provider can natively settle it and a charging rate is available, the checkout is pinned to that currency and the buyer is genuinely charged in it. The response then carries a presentment overlay:
const checkout = await client.getCheckout(id);
const shown = checkout.presentment
? formatPrice(checkout.presentment.total, { currency: checkout.presentment.currency })
: formatPrice(checkout.total, { currency: checkout.currency });presentment.total equals the amount charged; presentment.fxChargingRate and fxBufferPercent tell you the rate used. Any one of those three conditions failing makes the platform fall back to base currency rather than mis-charge, so presentment being absent is a normal outcome, not an error. Always branch on its presence; never assume the region currency was charged.
Do not mix currencies inside one number: never add a displayPrice to a base-currency amount, and never sum a presentment.* field with a checkout.* field.
Payment providers beyond Stripe
The payment step is ONE call, for every provider:
const session = client.startPayment(checkoutId, { container, onSuccess, onFailure, onError }).
It creates the payment and shows it the way the platform says (redirect,
provider page in an iframe, Brainerce-hosted card form, JavaScript widget, or a
test order). Never branch on clientSdk.renderType or on the provider name
yourself, never call a provider global (window.<providerGlobal>), and never build
the payment iframe, its postMessage listener or the redirect yourself. Full
example in Core, Step 5.3.
onPresentation('provider-page')→ wrap the container in a modal;'embedded'and'widget'are inline;'redirect'means the browser is leaving.presentation === 'sandbox'→ show a "Complete test order" button that callssession.completeTestOrder().session.reopen()shows the same payment again (closed modal, failed attempt).session.destroy()on unmount.onSuccess({ checkoutId, orderId, orderNumber })fires only after the server has the order → navigate to/order-confirmation?checkout_id=....onError(e): switch one.code.session_locked→ offer "Start a new checkout" (go to/checkoutwithoutcheckout_id);load_failedwithe.url→ offer "Open in a new tab";unconfirmed→ "We are confirming your payment, your order will appear shortly"; anything else → a friendly error.- Two return pages on your own origin:
/payment-completecallsrelayPaymentReturn()and renders nothing else;/order-confirmationcallsclient.completePaymentReturn(checkoutId)→{ status: 'succeeded' | 'pending' | 'failed' | 'canceled', orderId?, orderNumber? }. - Security is inside the call: allowlisted payment pages (
PAYMENT_PAGE_HOSTS) and widget script hosts, messages accepted only from the payment iframe's own window and origin, completion handled once, server confirmation before success. A custom self-hosted provider adds hosts withextraPaymentHosts/extraScriptHosts. - CSP: build it with
buildStorefrontCsp. Itsframe-srccovers every payment host and your store'sapiOrigin.
Stripe and PayPal are redirect providers. Stripe returns a hosted Checkout
Session URL and comes back with session_id; PayPal returns an approve URL and
comes back with token and PayerID. There is no Stripe Elements card form, no
publishableKey on the intent, and no PayPal JS buttons. Sola is iframe.
Which mode any other provider uses (sdk-widget, iframe, redirect, or
redirect by default with iframe on request) is returned by the API
(displayModes, renderMode, renderType); never branch on a provider name.
sandbox is the platform's test mode. 'embedded-fields' is reserved and no
provider produces it.
The rest of this section is what startPayment does internally. You need it
only without the SDK.
There is no
paymentUrlfield. Earlier revisions of this page described a hosted-page branch keyed onpaymentIntent.paymentUrl. No such field is ever returned, so the branch was dead. The URL a hosted-page provider wants you to open arrives inclientSdk.renderArg, falling back toclientSecret, and which surface to open it in is told byclientSdk.renderType.
The payment/intent response is:
{
"id": "pi_abc123",
"clientSecret": "https://pay.example-gateway.test/...",
"amount": "499.80",
"currency": "ILS",
"status": "pending",
"provider": "example-provider",
"metadata": {},
"clientSdk": { "renderType": "iframe" },
"renderModeResolution": "provider-default"
}The URL always comes from clientSdk.renderArg, with clientSecret as the
fallback. Write clientSdk.renderArg || clientSecret in every branch that
needs a URL: the iframe src, the redirect target, the href of a manual
"continue to payment" link, and the argument you pass to
clientSdk.renderMethod.
clientSecret is overloaded on purpose: for Stripe it is the hosted Checkout
URL, for some providers an auth code, and for several providers a copy
of the same URL that is in renderArg (PayPal and Sola, for example). That copy
is what makes reading clientSecret look correct until you meet a provider that
does not make it: some return a real identifier there, as the contract
intends, so the allowed-host check rejects it and the shopper never reaches the
payment page. Some providers (Stripe among them) set no renderArg at all, so
the fallback keeps their behaviour unchanged. renderType tells you which surface to open, never
which field to read. Full reasoning in
Core, Step 5.3.
Without the SDK, branch on clientSdk.renderType, never on the provider name, and never on
"clientSdk exists". Every provider, even the sandbox one, returns a
clientSdk object.
clientSdk.renderType | What you read | How to handle |
|---|---|---|
'sandbox' | nothing | No payment UI. Call completeCheckout directly. |
'sdk-widget' | renderArg || clientSecret as the render argument, plus initConfig | The provider's JavaScript widget. Needs the SDK's script loader: client.startPayment (or the lower-level mountPaymentWidget(intent, handlers)). Never the provider global's init / renderPaymentOptions yourself. |
'iframe' | renderArg || clientSecret as the URL | Load it in an iframe. Path contains /embed/ → render inline; anything else → render in a modal overlay. On brainerce:payment-complete, call confirmSdkPayment(checkoutId, e.data.data) before navigating. This is required for confirm-time-charge providers (Sola), not optional. |
'redirect' | renderArg || clientSecret as the URL | Navigate the top-level window to it. On return, POST /payment/sdk-confirm once, then poll payment-status. |
Full code for each branch, including the postMessage contract for the inline
embed and the isAllowedPaymentUrl() allowlist you must run before any
provider-requested navigation, is in Core, Without the SDK. Do not
re-derive it here.
You may ask for 'redirect' or 'iframe'; you may not assume you got it.
createPaymentIntent(checkoutId, { preferredRenderType: 'iframe' }) is honoured
only when the provider lists that mode in its clientSdk.displayModes (from
getPaymentProviders()); otherwise the provider's default comes back and
renderModeResolution is 'fallback'. Asking is never an error. Predict the
answer (without the SDK; startPayment does it for you) with resolveRenderType(provider.clientSdk, preferred) BEFORE the call so
the successUrl you pass matches the mode: an iframe intent returns the shopper
inside the frame to a same-origin transition page, a redirect intent returns them
to your confirmation page. Then branch on the renderType that came back, exactly
as above. There is no dashboard setting for this and there should not be: the
storefront knows whether it has a frame to render into, the merchant does not.
Details in Core, Step 5.2.
For hosted-page providers (renderType: 'redirect': Stripe, PayPal and others):
import { safePaymentRedirect } from 'brainerce';
if (paymentIntent.clientSdk?.renderType === 'redirect') {
// renderArg is the URL; clientSecret is only the fallback.
safePaymentRedirect(paymentIntent.clientSdk.renderArg || paymentIntent.clientSecret);
}After the shopper returns to your site, the SDK's client.completePaymentReturn(checkoutId)
does all of this for you. By hand: call client.confirmSdkPayment(checkoutId)
once (PayPal's return carries token and PayerID; the server verifies and captures).
That call is idempotent and safe to skip on failure. Then poll
client.getPaymentStatus(checkoutId) (Core, Task 5, step 5.4). If the shopper cancelled and landed on
your cancelUrl, do not call it; just let them retry.
// Signature: confirmSdkPayment(checkoutId, providerResponseData?) => { confirmed: boolean }
try {
await client.confirmSdkPayment(checkoutId);
} catch {
// Not fatal — payment-status polling re-verifies server-side.
}A
400 Payment not yet confirmed (status: processing)fromconfirmSdkPayment()is not a failure. Some providers answerprocessingfor minutes while they settle. The payment is left untouched,getPaymentStatus()keeps returningpendinguntil the provider's webhook settles it, and onlyfailedorcanceledmean "try again". Keep polling; do not callcreatePaymentIntentagain because of it (a second intent lets the buyer pay twice).startPayment()already behaves this way and ends in theunconfirmederror code while the order is still on its way.
confirmSdkPayment()andgetPaymentStatus()are sales-channel mode only. Both throwBrainerceError400 ("… is only available in sales-channel mode") on a client built withstoreIdorapiKey. Build the payment return page against asalesChannelIdclient.
Required pages
Your website MUST have these pages/routes for the integration to work:
| Page | Purpose | When needed |
|---|---|---|
| Product listing | Display products from Brainerce | Always |
| Product detail | Single product page with variant selection | Always |
| Cart | View and manage cart items | Always |
| Checkout | Multi-step checkout form | Always |
| Order confirmation / return URL | Show success after payment, handle redirect from payment providers (client.completePaymentReturn) | Always |
| Payment complete | /payment-complete: where a payment iframe lands. Calls relayPaymentReturn() and renders nothing else | Always |
| Order lookup | Let guests check order status by email + order number | Recommended |
| Login / Register | Customer authentication | If implementing Task 8 |
| Password reset | Reset password form (receives ?token= from email) | If implementing Task 8 |
| Wishlist | /wishlist: the saved items of a guest (browser) or a signed-in customer (server), resolved to live products with client.wishlist.resolve(keys). Needs SDK 3.16.0 or newer | Optional (wishlist) |
| Auth callback | Handle the OAuth return. Receives ?oauth_success=&auth_code= on success and ?oauth_error=&error_description= on failure, never code/state, which the platform's own callback consumes | If implementing Task 10 |
The order confirmation page is critical. It must:
- Read
checkout_idfrom the URL - Call
client.completePaymentReturn(checkoutId)(without the SDK:sdk-confirmonce, then poll payment status and callcompleteCheckoutif the order is not yet created) - On
succeeded: clear the cart and all localStorage keys, show the order number - On
pending: show "We are confirming your payment, your order will appear shortly" - On
failed/canceled: link back to/checkout?checkout_id=...so the shopper can pay again
Price & currency formatting
The SDK ships formatPrice(amount, { currency, locale? }) and takes an options object, never a positional currency code (formatPrice(amount, 'USD') is the mistake the SDK README calls out). If you are on raw REST and write your own, give it the same signature so a later switch to the SDK import is a no-op:
function formatPrice(price, { currency, locale } = {}) {
const amount = typeof price === 'number' ? price : parseFloat(price);
if (isNaN(amount)) return String(price);
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
}
// formatPrice("150.50", { currency: "ILS" }) → "₪150.50"
// formatPrice("29.99", { currency: "USD" }) → "$29.99"Conditional flow decision trees
Product type → Add to Cart
product.type === "SIMPLE"?
├── YES → { productId: product.id, quantity }
│ Do NOT include variantId
└── NO (VARIABLE)
├── User selects all attributes → find matching variant
├── variant found AND in stock?
│ ├── YES → { productId: product.id, variantId: variant.id, quantity }
│ └── NO → Disable "Add to Cart", show "Out of Stock"
└── Not all attributes selected → Disable "Add to Cart", show "Select options"Checkout flow
Create checkout
→ Set customer email (required)
→ hasShipping?
├── YES → Set delivery type
│ ├── "shipping" → Address → Shipping method (if rates exist)
│ └── "pickup" → Select pickup location
└── NO → Skip to payment
→ (Optional) Billing address
→ Order summary
→ features.hasGiftCards?
├── YES → offer the gift-card field (apply / remove), then re-read the checkout
│ total is UNCHANGED; render checkout.tenders below it, then "Amount due"
│ providerAmountDue === "0.00"? → completeCheckout directly, no payment
│ step — then still clear the cart, as after any completed order
└── NO → nothing to render
→ client.startPayment(checkoutId, {...}) (must come AFTER any gift card;
apply/remove 400s once the checkout is PAYMENT_PENDING:
CHECKOUT_LOCKED). Start it from a click.
├── onPresentation → 'provider-page' in a modal, 'embedded' / 'widget' inline,
│ 'redirect' leaves the page, 'sandbox' → "Complete test
│ order" button → session.completeTestOrder()
├── onSuccess → the server has the order → /order-confirmation?checkout_id=
├── onFailure → show the message, "Try again" → session.reopen()
└── onError → session_locked → new checkout; load_failed → open e.url in
a new tab; unconfirmed → "confirming"; else a friendly error
→ /order-confirmation → client.completePaymentReturn(checkoutId)
→ Clear localStorage → Show confirmationGuest vs. logged-in
Do we hold a customer token in memory (or an HttpOnly session cookie)?
├── YES (logged in)
│ ├── Pre-fill checkout from /customers/me/checkout-prefill
│ ├── Show profile, orders, addresses links
│ └── Cart auto-linked to customer
└── NO (guest)
├── Show login/register links
├── Cart uses sessionToken
├── After login → merge cart (Task 8, step 8.3)
├── Saved items (wishlist, SDK 3.16.0+): client.wishlist.local.* in the browser;
│ after login `await client.wishlist.syncOnLogin()`; signed in → client.wishlist.*
└── Order lookup via email + orderNumberPayment provider decision
There is no decision for you to make: client.startPayment() reads the
clientSdk.renderType the platform returns and shows the payment. You only
choose a wrapper from the presentation it reports.
client.startPayment(checkoutId, { container, onPresentation, ... })
onPresentation(presentation)
├── "widget" → inline (the provider's JavaScript payment window)
├── "embedded" → inline (Brainerce-hosted card form, Sola for example)
├── "provider-page" → wrap the container in a modal (the provider's page
│ inside an iframe)
├── "redirect" → nothing; the browser leaves (Stripe, PayPal and other
│ hosted pages) and returns to /order-confirmation
└── "sandbox" → "Complete test order" button → session.completeTestOrder()Without the SDK, switch on paymentIntent.clientSdk?.renderType (never on
whether clientSdk exists, and never on the preferredRenderType you passed):
'sandbox' → completeCheckout; 'redirect' → navigate to
renderArg || clientSecret; 'iframe' → load it, inline when the path contains
/embed/, else in a modal, and on brainerce:payment-complete call
confirmSdkPayment(checkoutId, e.data.data) BEFORE navigating (Sola only
tokenizes inside the iframe; the charge runs at confirm time, and Sola is
excluded from server-side reconciliation, so skipping it leaves the checkout
stuck in PAYMENT_PENDING); 'sdk-widget' → needs the SDK's script loader;
anything else → surface an error, do NOT guess.
Layout the providers by methodType first. GET /payment/providers returns
one primary card processor (isAdditive: false, the defaultProvider) and any
additive methods (isAdditive: true, e.g. PayPal). Render additive methods as
express buttons above the primary card form, never as a replacement or a
peer radio option that could hide the card form. Start the payment with the
tapped provider's id: client.startPayment(checkoutId, { providerId, ... }).
Common AI mistakes to avoid
1. Sending variantId: null for SIMPLE products
Wrong: { "productId": "prod_abc", "variantId": null, "quantity": 1 }
Right: { "productId": "prod_abc", "quantity": 1 }, omitting variantId entirely.
2. Using parseInt() on prices
Wrong: parseInt("299.90") → 299 (loses decimals)
Right: parseFloat("299.90") → 299.9
3. Calculating totals manually
Wrong: Sum up item prices yourself.
Right: Use checkout.total from the API response. The server applies discounts, tax, shipping.
4. Calling completeCheckout before payment
Wrong: User clicks "Place Order" → completeCheckout (no payment collected).
Right: Create payment intent → process payment → verify status → THEN completeCheckout.
Exception: in sandbox mode (provider === "sandbox"), call completeCheckout directly.
5. Losing the checkoutId across the payment return
Wrong: a redirect provider (Stripe, PayPal) returns to a confirmation page that does not know which checkout it is for.
Right: let client.startPayment() build the return URLs (they carry checkout_id), and on /order-confirmation read checkout_id from the URL and call client.completePaymentReturn(checkoutId).
6. Creating new cart/checkout after payment failure
Wrong: Payment fails → create new cart + checkout. Right: Payment fails → show error → let user retry. Same checkout is still valid.
7. Not handling cart 404
Wrong: Load cart → 404 → crash. Or: any 404 on a cart write → throw the cart away and start a new one.
Right: Read the code. CART_NOT_FOUND on the cart you loaded → create a new cart → save new sessionToken. CART_ITEM_NOT_FOUND, PRODUCT_NOT_FOUND and VARIANT_NOT_FOUND say nothing about the cart: re-read the cart or tell the shopper the item is unavailable, and keep the cart. The SDK already does this split for you (see Core, Step 3.3b).
8. Hardcoding currency
Wrong: <span>₪{price}</span>
Right: <span>{formatPrice(price, { currency: storeConfig.currency })}</span>
9. Not checking inventory
Wrong: Always show "Add to Cart". Or compute stock as inventory.quantity - inventory.reserved: neither field exists on a public product read, so that expression is NaN and every card renders as in stock.
Right: Gate on inventory.canPurchase (inventory is { trackingMode, total, available, inStock, canPurchase, backorderMode? }; available is a count only when trackingMode is TRACKED, and is never negative). canPurchase already folds in the merchant's backorder setting: an item that allows backorders (backorderMode ALLOW or NOTIFY) keeps canPurchase: true at zero stock, with inStock: false, until its backorder limit is reached, so gate the button on canPurchase and show "ships when back in stock" (never "in stock") when canPurchase && !inStock. For VARIABLE products, check the selected variant's inventory. For a KIT, read kitAvailable (null = unlimited, 0 = not sellable); kits are never backorderable.
10. Forgetting Authorization header
Endpoints /customers/me/*, /cart/merge, /cart/{id}/link, OAuth linking need:
Authorization: Bearer {customerToken}
The brainerceAPI helper handles this if token is in localStorage.
11. Omitting productId/variantId on marketing-tag items
Wrong: trackMarketingEvent('purchase', { items: [{ itemId: item.sku }] })
Right: items: [{ itemId: item.sku, productId: item.productId, variantId: item.variantId }]
itemId (SKU-preferring) is only the Meta/TikTok pixel id. For GA4/GTM,
trackMarketingEvent builds its own item_id from productId/variantId
(prod_<productId> / var_<variantId>) — matching what Brainerce actually
publishes to the Google Merchant Center feed's offerId. Sending only
itemId (whether SKU or a bare product/variant id) fails silently for
GA4: the events arrive, GA4 reports an id its Merchant Center feed has never
seen, Shopping ads / dynamic remarketing item matching quietly stops working,
and nothing errors anywhere.
12. Asking the merchant for a GA4 / pixel id
Wrong: an env var, a config file, or a setup prompt for G-… or a pixel id.
Right: client.initTracking((await client.getStoreInfo()).tracking).
The ids are resolved server-side from the marketplace apps the merchant already
connected. An empty tracking object means no tag app is connected, which is a
normal state and not a reason to prompt.
13. Marketing tags behind a CSP with no connect-src entries
Wrong: connect-src 'self' https://api.brainerce.com and a Meta pixel that
"loads fine."
Right: add the vendor beacon hosts. See the marketing-tags section of the
integration guide.
The tag scripts load and then every hit is blocked. In the ad dashboard this is
indistinguishable from having made no sales. Under script-src 'strict-dynamic'
do NOT add them to script-src; host allowlists are ignored there.
14. Firing purchase without transactionId
Wrong: trackMarketingEvent('purchase', { value, currency, items })
Right: include transactionId: order.id.
It becomes GA4's transaction_id, Meta's eventID and TikTok's event_id. A
shopper who refreshes the confirmation page otherwise counts as a second sale,
inflating reported revenue. Guard the call yourself too (a sessionStorage key
per order id).
15. Telling the visitor they're subscribed
Wrong: await marketing.subscribe(...) → "You're subscribed! 🎉"
Right: "Check your email to confirm."
subscribe() starts a confirmed opt-in. The address is not on the list and no
campaign can reach it until the recipient clicks the link in their inbox. The
success response is { ok: true } for every case, including an address that is
already subscribed and one suppressed after a hard bounce, so there is nothing
in it to branch on. Most people never click; a storefront that claims otherwise
is why the merchant's list looks bigger than their sends.
The same rule covers the welcome offer. If the merchant configured one,
marketing.getBenefit(locale) gives you the terms to advertise beside the field
(SDK >= 2.7). It does not give you a code, and there is none to show: the coupon
is minted when the recipient clicks the confirmation link and is emailed to them
at that moment. That is deliberate, because a code rendered on a public form is
a code anyone can pass on. Do not build a "preparing your code" screen either;
the confirmation page is served by the API and already shows the code, its
expiry and its terms.
getBenefit() accepts no email address and there is no eligibility check to
call. A per-address answer would be an unauthenticated way to test who already
subscribed, which is the leak the uniform { ok: true } exists to prevent. The
offer is one per address per store, forever: say so in the terms rather than
trying to detect a repeat.
16. Wording a back-in-stock alert as a subscription
Wrong: await stockAlerts.subscribe(...) → "Subscribed! 🎉"
Right: "We'll email you when it's back."
It is one message about one item, not a mailing list. Nobody is subscribed to anything, no customer account is created, and the person hears nothing else as a result. Someone who reads "subscribed" and gets no newsletter has been misled; someone who never wanted one has a complaint. For the same reason, do not hide the button from a shopper who has unsubscribed from marketing, because they can still legitimately ask about a product.
And do not promise timing. The alert waits for stock to hold, then goes out in waves sized to the units that arrived, so a shopper can sit through a restock without hearing. "We'll email you when it's back" is true; "you'll be the first to know" is not.
17. Putting the button on an item that cannot use it
Wrong: rendering "notify me" wherever inStock === false.
Right:
store.stockAlertsEnabled !== false &&
inv?.trackingMode === 'TRACKED' &&
!inv.canPurchase &&
(inv.backorderMode ?? 'NONE') === 'NONE';Requests for anything else are silently ignored. The response is uniform on
purpose, so it cannot be used to read a store's stock levels, which means a
button in the wrong place looks like it worked and does nothing. The merchant
switch is the one most integrations forget: it lives in channel settings, it can
be turned off at any time, and the only way your storefront learns about it is
stockAlertsEnabled on getStoreInfo().
Same for a variable product with no variantId: the alert then waits on the
product as a whole, and a shopper who wanted the medium is mailed when the small
returns.
18. Showing a gift card as a discount
Wrong: adding amountApplied to discountAmount, or subtracting it from
total, so the summary reads "Total ₪150.50".
Right: leave total exactly as the API sent it and add two lines under it:
Total ₪205.00 ← unchanged; this is the amount tax was calculated on
Gift card −₪54.50 ← checkout.tenders[].amountApplied, its own line
Amount due ₪150.50 ← checkout.providerAmountDueA gift card is a means of payment, not a price reduction. The order is still
worth what it is worth, and tax is still charged on that, which is why the
platform returns providerAmountDue as its own field instead of moving the
total. A storefront that folds it into the discount shows the shopper — and
prints on their receipt — a taxable base that is smaller than the one they were
actually charged tax on.
Two follow-ons from the same mistake: read applied cards from
checkout.tenders, not from the applyGiftCard response you kept in state (the
hold lives on the server, so your copy vanishes on reload while the card is
still applied), and remove by tenderId, never by code, because a checkout can
carry several cards.
19. Explaining why a gift card was refused
Wrong: if (err.message.includes('expired')) show('This card has expired').
Right: one message for every refusal — "We can't use this code" — and let
the shopper try again.
Unknown, expired, spent, disabled and wrong-currency all come back as the same
400 with the same sentence, deliberately, because anything else is an oracle for
walking the code space. checkGiftCardBalance is the same: an unknown, disabled
or expired code all answer { balance: "0.00", usable: false }. There is
nothing to branch on, so a branch you write there is dead code that will one day
tell a shopper the wrong thing.
20. Assuming the Google Customer Reviews widget renders in the store's language
Wrong: rendering the survey without checking languageSupported because the
rest of your storefront is already localized.
Right: check survey.languageSupported and only pass survey.lang when it's
truthy; if false, still render the widget (Google decides the fallback) but
don't claim it will appear in the shopper's own language.
Google's Customer Reviews widget supports a fixed list of languages, and Hebrew
is not on it. getGoogleCustomerReviewsSurvey() already resolves this for you —
do not build a second, guessed mapping from store language to widget language.
21. Writing the payment step yourself
Wrong: branching on clientSdk.renderType or the provider name, calling
the provider global's init(...) and renderPaymentOptions(clientSecret) yourself,
building the payment iframe and its message listener by hand, or mounting
Stripe Elements / PayPal buttons (neither exists on this platform: Stripe and
PayPal are redirect providers).
Right: one call, client.startPayment(checkoutId, { container, onSuccess, onFailure, onError }),
plus the two return pages (relayPaymentReturn() on /payment-complete,
client.completePaymentReturn(checkoutId) on /order-confirmation).
Storefronts that wrote this logic themselves broke on a live store three ways in
one day: a render type they did not handle, a partial widget descriptor, and a
widget provider's start-up message ("SDK was not loaded as needed and therefore
can't run", "Wallet not initialized") shown to shoppers as "payment error". Those
messages are transient; startPayment rides them out, accepts iframe messages
only from the payment iframe's own window and origin, handles a completion once,
and reports onSuccess only after the server has the order. Call
session.destroy() on unmount. Full example in
Core, Step 5.3.
22. Showing "Your cart is empty" before the cart has loaded
Wrong: cart ? (cart.items.length ? <Lines/> : <EmptyCart/>) : <EmptyCart/>, or an empty-cart state whenever the cart request has not answered or has failed.
Right: three distinct states. Loading shows a skeleton (size it from the remembered item count, hintCount in the cart store, capped at a few rows). Error shows a message with a Retry button. Empty is rendered only when the server answered and the cart has no lines.
A shopper with a full cart who sees "Your cart is empty" for half a second, or for good after one dropped request, believes the cart was lost and leaves. The cart store's status (loading | empty | ready | error) gives you the three states without writing them. See Core, What the cart page must show.
23. Changing the cart and then fetching it again
Wrong: await addToCart(...) then await getCart(...) to "refresh"; or one request per tap on a quantity stepper; or sending the shopper to checkout while their last tap is still in flight.
Right: every cart write answers with the whole updated cart, so render that response and do not refetch. Show a new quantity at once (optimistic) while keeping totals as the server last returned them, keep one write per cart in flight and send only the latest wanted quantity when it answers (a burst of taps is at most two requests), and before navigating to checkout const { ok } = await cart.flush(); and continue only when ok.
The refetch costs a second round trip, spends the per-IP GET /cart/{id} limit (see Rate limiting), and can land after the next tap and paint an older cart over a newer one. The cart store (createCartStore, CartProvider + useCart()) does all of this: prefer it over hand-written cart state. See Core, Step 3.3b.
24. A plain anchor for an internal link
Wrong: <a href="/cart">, <a href="/products"> or a logo <a href="/"> in the header and footer.
Right: the framework's router link: next/link in Next.js, the starter's SmartLink from @/core/lib/navigation, <Link> in Remix and React Router. External links, target="_blank" links and file downloads may stay plain anchors.
A plain anchor makes the browser load a new document. The JavaScript state is thrown away, including the cart that was already loaded, so the next page starts from the loading skeleton and a fresh network request on every click.
25. Floating widgets on top of the cart and checkout
Wrong: a newsletter popup, chat bubble (including the Storefront Bot), cookie banner or accessibility button that sits over a quantity stepper, a coupon field or a checkout input.
Right: hide floating widgets on /cart and /checkout, and test the pages on a 390 px wide phone. The primary button under the order total is the checkout button, and on phones a sticky checkout bar is expected.
A control that is covered is a control that cannot be tapped. Shoppers do not report it; they leave.
26. Checkout inputs without autocomplete tokens
Wrong: <input placeholder="Postcode" />.
Right: <input name="postalCode" autoComplete="postal-code" />, with the token for every field (email, given-name, family-name, tel, postal-code, address-level1, address-level2, address-line1, address-line2, country), plus inputMode="email" and inputMode="tel" on those two. Table: Validation rules, Checkout.
Without them a phone cannot fill the saved address and shows the wrong keyboard, and the shopper types a whole address with their thumbs. One exception: a typeahead line1 field keeps autoComplete="off" so the browser's saved-address list does not open over yours.
27. Running the storefront on the default Vercel function region
Wrong: deploying to Vercel and leaving the function region at its default (iad1, Washington, D.C.) while the Brainerce API runs in Europe.
Right: pin the Vercel function region to Frankfurt (fra1): { "regions": ["fra1"] } in vercel.json, or Project Settings → Functions → Function Region, then redeploy. Let the browser call the API directly for public reads (products, categories, search) instead of routing them through your own server.
Every call a function makes pays that distance: roughly 300 to 450 ms added to each proxied call. This is the Vercel function region, not a commerce region (see Regions). See Core, Vercel function region.
28. Guest cart writes without proof of ownership
Wrong: your own fetch to POST /cart/{id}/items with only the cart id, a proxy that rebuilds the URL and drops the query string, or treating 403 CART_ACCESS_DENIED as "session expired" and logging the shopper out.
Right: send the guest cart's sessionToken as the cartToken query parameter (the SDK does it for you), forward the query string through any proxy, redact cartToken in logs, and on CART_ACCESS_DENIED fix the proof without touching the customer session. Never retry it unchanged.
It only fails on stores that enforce the proof, so it passes in testing on an older store and breaks on a new one. See Core, Cart ownership proof.
29. Reading the recovery link's ?brainerce_cart yourself
Wrong: new URLSearchParams(location.search).get('brainerce_cart') after the SDK client exists, to decide which cart to load.
Right: call smartGetCart() (or use the cart store) and render the cart it returns. The recovery link is ?brainerce_cart=<id>#brainerce_recover=<code>, and the SDK strips both from the address bar when the client is constructed and exchanges the one-time code for the cart. Your read sees nothing, and the shopper gets an empty cart instead of the recovered one.
Without the SDK you do the exchange yourself in the browser: see Core, Abandoned-checkout recovery.
30. Wishlist mistakes (client.wishlist, SDK 3.16.0 and newer)
Wrong:
- Calling
client.wishlist.add()for a guest, orclient.wishlist.local.add()for a signed-in customer. The SDK does not decide which list applies: your code pickslocalfor a guest and the server methods for a customer. A guest'sadd()fails with 401. - Showing "Your wishlist is empty" when
keys()threw or the request failed. - Rendering saved items from the stored ids (or a stored copy of name and price) instead of
client.wishlist.resolve(keys). A copy shows a price and a stock state that are weeks old, and shows products the merchant has since unpublished. - Forgetting
await client.wishlist.syncOnLogin()after a sign-in path, so the guest list stays in the browser while the shopper believes it is on their account. - Writing
client.wishlist.*against an SDK older than 3.16.0, or inventingaddToWishlist/getWishlist/ a/wishlistREST route outside the five documented ones. Before 3.16.0 the platform has no wishlist.
Right: pick the list from your own auth state, render loading / error (with a retry) / empty / ready as four different things, resolve ids through the catalog, and sync at login and on page load. See Optional, Wishlist.
The merchant sees counts of signed-in customers only; a guest's list never leaves the browser, so do not promise a guest anything the merchant can act on (no emails, no "someone saved this" message). There are no price-drop emails, share links, webhook events or several lists per customer.
Checklist
Products & Navigation
- Capabilities load correctly
- Categories display in navigation
- Products load with images, prices, stock
- Sale prices: crossed-out original + sale price
- Out-of-stock: disabled "Add to Cart"
- Out-of-stock (no backorder): "Email me when it's back" button, with variantId
- Wishlist (optional, SDK 3.16.0+): a heart per product, list picked from auth state (guest
local, customer server),syncOnLogin()after login and on page load, saved items resolved withresolve(keys), a failed load shown as an error with a retry, never as an empty list - Low stock warning shows
- VARIABLE variant selectors work
- SIMPLE products add without variantId
- Product page loads by slug
- Search suggestions work
- Pagination works
- Empty state: "No products found"
Cart
- Cart creates on first visit
- Cart loads from localStorage on return
- 404 creates new cart
- Add works for SIMPLE and VARIABLE
- Quantity update works
- Remove works
- Empty cart message
- Coupon apply/remove (if hasCoupons)
- Totals correct (subtotal, discounts, total)
- Nudges display
- Cart badge shows itemCount
- Cart page shows a skeleton while loading (never the empty message), an error state with a Retry button when loading fails, and the empty message only when the server said so
- Every internal link uses the framework router link (
next/link,SmartLink), never a plain<a href>; navigating from the header does not reload the page - A cart write is never followed by a refetch: the returned cart is rendered; quantity changes are optimistic and rapid taps are coalesced (the cart store does this)
- Checkout is entered only after
await cart.flush()returnedok - Guest cart writes carry
cartToken(automatic with the SDK; your own helper if raw REST) and403 CART_ACCESS_DENIEDdoes not log the shopper out - Floating widgets (popup, chat bubble, cookie banner, accessibility button) are hidden on
/cartand/checkoutand never cover quantity controls or checkout fields; the primary button under the order total is the checkout button, with a sticky checkout bar on phones - On Vercel, the Vercel function region is pinned next to the API (
fra1for Europe), andcartTokenis redacted in proxy and function logs
Checkout
- Checkout creates from cart
- Checkout inputs carry
autocompletetokens, aname, andinputmodefor email and phone - Customer email required
- Shipping flow works (if hasShipping)
- Pickup flow works (if applicable)
- Shipping method selection works
- Order summary correct (uses API total)
- Gift-card field rendered when
features.hasGiftCards(and nothing when it is false) - Gift card shown as its own line below the total, plus an "Amount due" line — never inside the discount block, and
totalnever modified - Applied cards read from
checkout.tenderson every render, so they survive a page reload - Remove uses
tenderId, never the code - One refusal message for every rejected code, with no attempt to say why
- Gift-card field hidden once the payment intent exists
Payment
- Payment providers load
-
providerAmountDue === "0.00"(gift cards cover the order) skips the payment step, callscompleteCheckoutdirectly, and still clears the cart afterwards - The charged amount is the intent's own
amount— gift cards are never subtracted client-side - Payment step is one
client.startPayment()call, started from a click; norenderTypeor provider-name branching, no provider globals, no hand-built payment iframe -
provider-pagein a modal,embedded/widgetinline,sandboxshows "Complete test order" (session.completeTestOrder()) -
onErrorhandlessession_locked,load_failed(withe.url),unconfirmed -
session.destroy()on unmount -
/payment-completecallsrelayPaymentReturn()and renders nothing else -
/order-confirmationcallsclient.completePaymentReturn(checkoutId) - CSP from
buildStorefrontCsp - Order created after payment verified
- Order number displayed
Post-Purchase
- localStorage cleared
- Confirmation email mentioned
- Order status page reads the order by
checkout_idfrom the email link - Order status displays correctly
- Tracking link shows when available
Errors
- Product 404 handled
- Out-of-stock handled
- Payment failures: user-friendly message
-
503 PAYMENTS_PAUSEDfromcreatePaymentIntent: message shown, cart kept, no retry loop -
createPaymentIntent/startPaymentsendexpectedTotal; a 409CHECKOUT_TOTAL_CHANGEDshows the new total and waits for the buyer to accept before retrying - Shipping form collects
region(state / province) for US, CA and AU; a 400TAX_ADDRESS_REGION_REQUIREDasks for it and retries - Expired checkout: new one created
- Network errors: retry message
- 401: token cleared
- 500: generic message
- No raw errors shown for payment/500
Customer (if implemented)
- Register works
- Login works
- Cart merges after login
- Logout clears state
- Password reset works
- Email verification works
- Profile loads and updates
- Addresses CRUD works
- Order history loads
- Checkout pre-fills from profile
- Social login works (if implemented)
Product Reviews
- Customer auth required. Submit / update / delete all require a customer JWT (
setCustomerToken(...)after login). Listing existing reviews needs no login, but it is not unconditionally public: it answers403withdetails.code === 'REVIEWS_DISABLED'while the merchant has reviews switched off (see below). - Purchaser-only. The authenticated customer must have an eligible order containing the product. Otherwise
submitProductReviewreturns403 Forbiddenwithreason: 'no_eligible_order'.- Physical products → order status
SHIPPED/COMPLETED/DELIVERED. - Downloadable products → additionally
PAID/PROCESSING.
- Physical products → order status
- One review per customer per product. Duplicate submits return
409 Conflict. UseupdateMyProductReviewto edit,deleteMyProductReviewto start over. - No author name/email in the body. Both are derived server-side from the customer profile. Pass only
{ rating, body? }. ratingmust be an integer 1-5 (DB CHECK constraint rejects anything else).bodyis optional, max 5000 chars. It is plaintext, so always pipe it through your sanitizer before rendering.- Submit endpoint is rate-limited to 3 / 60s / IP; edit/delete to 5 / 60s / IP. Show the user "please wait a moment" on
429, do not retry automatically. verifiedPurchaseis alwaystruefor customer-submitted reviews (since they must purchase). External-api imports (Yotpo/Judge.me) create reviews withverifiedPurchase: falseand supply author info directly.hiddenAtis set only by merchants via the admin surface. Storefront list endpoint never returns hidden reviews.- Emit
aggregateRatingin Product JSON-LD only whenreviewCount > 0andgetStoreInfo().reviewsEnabled !== false. Do not invent values. A product keeps its storedavgRatingandreviewCountwhile reviews are off, so the rating describes reviews the shopper cannot see; leave it out. - Reviews off means no reviews UI at all. Stores with
reviewsEnabled = falseanswer every storefront review route (list, submit, edit, delete, photo upload) with403anddetails.code === 'REVIEWS_DISABLED'. ReadgetStoreInfo().reviewsEnabledfirst and render nothing when it isfalse: no reviews section, no review form, no star rating on product cards, noaggregateRating. Treat an absent flag (older backend) as on and fall back to catching theREVIEWS_DISABLED403. Branch on the code, never on the message wording, and never on the 403 alone: the origin check and a suspended store also answer 403. Any other failure (network, 5xx) is a real error, not "reviews are off". getMyProductReviewis the one exception: it does not throw while reviews are off, it answers 200 witheligible: falseandreason: 'reviews_disabled'(lowercase, areasonstring, not the errorcode).- Use
client.getMyProductReview(productId)to decide whether to render a sign-in CTA, a not-eligible message, a submit form, or an edit form. Returns{ eligible, reason, myReview, photos, myImages }.
Review photos
- Keys, never URLs.
imageKeysaccepts thekeyfromuploadReviewPhoto(). A URL is rejected. Keys are resolved against the store's own assets, and a key that already belongs to a different review is refused, so a photo published on one review cannot be lifted onto another. - Upload needs the same rights as the review.
uploadReviewPhotorequires a customer JWT and an eligible order, checked before the bytes are stored. This is stricter thanuploadCustomizationFile, which is anonymous by design. imageKeysreplaces the set on update. Send the keys you want to keep. Omitting the field leaves existing photos untouched;[]removes them all.- Max 5 photos per review, 5MB and 40 megapixels each, JPEG/PNG/WebP/GIF. The declared MIME is cross-checked against the file's real bytes, so a renamed SVG or arbitrary binary is rejected.
- EXIF is stripped, so a customer's GPS coordinates never reach the storefront, while the orientation tag is applied first so phone photos are not sideways.
- Photos count against the merchant's plan storage. An upload never attached to a submitted review is reclaimed after 7 days.
- Approval is off by default, matching review text. Stores that turn it on hold each new photo until the merchant shows it; photos already approved stay published. Read
photos.requiresApprovaland tell the customer. - On an approval-required store the submit/update response's
imagesis empty, because it carries only shopper-visible photos. Re-fetchgetMyProductReview()and rendermyImagesso the customer sees their pending upload. listProductReviewsdefaults tosort: 'photos_first'. Passsort: 'newest'for chronological order.review.imagesis always an array. Renderimg.width/img.heighton the<img>to avoid layout shift.
Content (typed merchant content)
- Sanitize before rendering.
FAQ.items[i].answer,RICH_TEXT.html,PAGE.html, andProduct.descriptionare merchant-authored HTML. The server does NOT pre-sanitize FAQ/RICH_TEXT/PAGE. ALWAYS pipe throughisomorphic-dompurify(or equivalent) beforedangerouslySetInnerHTML. Skipping this is XSS.Product.descriptionmay also contain<video>and host-locked YouTube/Vimeo<iframe>embeds, so your sanitizer must allow those tags (iframe restricted towww.youtube.com/www.youtube-nocookie.com/player.vimeo.com) and your CSPframe-srcmust list those hosts. See Core Integration → Product descriptions can contain video and embeds. get()/getBySlug()returnnullon 404. Render a hard-coded fallback whennullso the page never crashes when the merchant hasn't seeded yet.list()returns[]on empty.'main'is the universal default key for every type.client.content.faq.get()(no args) resolves tokey='main'. Don't ship topical entries under'main'.keyis immutable after save. The dashboard disables the field on edit. Plan the slug carefully on first create:^[a-z][a-z0-9-]*$, max 64 chars.- Topical entries use kebab-case keys (
'shipping','returns','holiday-2026'). Don't mix'main'and topical rows for the same surface; pick one convention. - Public reads are cached. Content and blog reads carry
Cache-Control: public, max-age=300, stale-while-revalidate=60; the other static reads (store info, capabilities, regions, tax classes, categories, brands, tags, custom-field definitions, pickup points, shipping destinations, OAuth providers, payment config and providers, contact forms, metafield filters, a category by slug, discount banners and the newsletter benefit) carryCache-Control: public, max-age=60, stale-while-revalidate=300withVary: Origin, Accept-Language, X-Brainerce-Features, Authorization. Only a successful answer carries it, and products, prices, stock, carts, orders, customers and/regions/autocarry none. Browsers honour it; a server does not (Next.js serverfetchis uncached by default), so a server-rendered storefront would repeat the same reads on every page view and hit the rate limit (HTTP 429). The SDK therefore keeps its own short cache for these reads (60 seconds by default, shared by every client in the process), shares one request between concurrent callers, and serves the last good value if a refresh fails. Don't wrap the same SDK call in a second cache without a reason, and keep any extra layer shorter than 5 minutes so merchant edits still show up.publicCache: falseturns it off,publicCache: { ttlMs }tunes it, andclient.clearPublicCache()(for example from acontent.*webhook) shows an edit at once. Products, prices and stock are never stored, but identical anonymous product reads running at the same moment share one request; discount banners are kept for 30 seconds and search suggestions for 10 (debounce the search box, about 250 ms). A signed-in customer adds, updates (withitemId) and removes cart lines with one request, like a guest who holds a cart. If your own server proxies the browser's calls (a BFF), the API sees that server's single IP for every shopper, so do two things there: forwardRetry-Afteron a 429 (the SDK's automatic retry needs it), and answer anonymous reads from the same allowlist withisCacheablePublicRead(path)andreadThroughPublicCache(...)(both exported frombrainerce), never for a request that carries the customer token and never storing a header. - Locale resolution is server-side. Pass
localetoget/list/getBySlug, and the server runs deep-merge with empty-string fallthrough and returns the resolveddata. Do NOT overlay translations client-side. - Locale fallback rules: missing locale / null / === default → returns base
dataunchanged.translations[locale]missing → returns base unchanged. Otherwise deep-merge (arrays merge by index). - Custom fields are merchant-defined.
row.customFieldsisRecord<string, string>. Read defensively (if (faq.customFields.helpEmail) { ... }) and never assume keys exist. - Channel scoping is enforced in sales-channel mode only. Storefront mode (raw
storeId) bypasses thesalesChannelIdsfilter, and non-empty restrictions are only honored when the request arrives via/api/vc/:connectionId/.... announcement.list()returns ALL active announcements. Filter client-side bystartsAt/endsAtISO timestamps so the cached server response can be shared across visitors.- Persist
announcement.dismissibledismissals inlocalStoragekeyed by announcementid(not by text, which may be edited). The scaffold's<AnnouncementBar>does this for you. - Admin write operations require
apiKeymode.client.content.faq.create()/update/publish/removethrowBrainerceError(403)when called from storefront / sales-channel mode. - Every admin content and blog call takes an explicit
storeIdas its last argument.content.<type>.create(input, storeId),content.update(id, input, storeId),content.publish(id, storeId),content.unpublish(id, storeId),content.remove(id, storeId),content.findById(id, storeId),content.listAdmin({ storeId, type?, status? }), and the matchingblog.*calls. Admin mode has no ambient store, since the constructor'sstoreIdonly takes effect in storefront mode, so the SDK sends it as a query param and never as a body field. A missingstoreId, or one naming a store your key is not bound to, is rejected by the store scope guard with403 STORE_SCOPE_REQUIREDbefore the handler runs, so do not write a handler for a 400. Scopes:content:read/content:write, andblog:read/blog:write. - The public reads throw in admin mode.
content.<type>.get(),content.<type>.list(),content.page.getBySlug()andblog.getPost(slug)are storefront APIs. From anapiKeyclient they throw rather than issue a request that could only fail, because the admin API has no by-key or by-slug read. Usecontent.listAdmin({ storeId, type })/content.findById(id, storeId), orblog.getPosts({}, storeId)/blog.findById(id, storeId). - The two admin
findByIdmethods disagree on 404.blog.findById(id, storeId)resolves tonull;content.findById(id, storeId)throws. Check which one you are calling before writing the miss path. - Always create in DRAFT. Publishing is a separate explicit step, so never call
publish()immediately aftercreate()without user confirmation. The dashboard enforces this; respect the same boundary in admin scripts. update()replacesdatawholesale. There is no partial-data patch. Read the row first (findById(id, storeId)/listAdmin({ storeId })), spread it, then send the merged result.remove()is irreversible. Storefronts depending on the row fall back to defaults or 404. Preferunpublish()for soft-removal.- Use
client.getStoreDirection(locale)for<html dir>. Returns'ltr' | 'rtl'for any BCP-47 tag (Arabic, Hebrew, Persian, Urdu, Yiddish today, plus any future RTL locales). Do NOT maintain a local RTL set.
SEO & Discoverability
- Use SDK JSON-LD builders, never hand-rolled objects.
buildProductJsonLd/buildArticleJsonLd/buildCollectionPageJsonLd/buildOrganizationJsonLd/buildWebsiteJsonLd/buildBreadcrumbJsonLd+jsonLdScriptPropsencode Google's structured-data rules (aggregateRating gating, AggregateOffer for VARIABLE products, XSS-safe serialization). Hand-rolled JSON-LD drifts from policy. buildProductJsonLd's Offer always includesitemCondition(hardcodedNewCondition),priceValidUntilwhen the product has an active sale-price window (salePriceEndsAt), andshippingDetailswhen you passshipping: storeInfo.shipping(real flat-rate/free zones, never fabricated, omitted entirely if you don't pass it).- Emit brand/Authority signals on the homepage.
buildOrganizationJsonLd(brand entity → Knowledge Panel + AI answers viasameAssocial profiles) +buildWebsiteJsonLdwithsearchUrlTemplatepointing at your real search route (sitelinks search box). These are the free off-page authority signals, and no paid backlink provider is needed. - Product JSON-LD on single-product pages ONLY. Never on category/listing pages, because Google's Product rich results reject listing-page markup. On a category page emit
buildCollectionPageJsonLd+buildBreadcrumbJsonLdinstead. - Every product page needs a
<link rel="canonical">. Next'salternates.canonicalingenerateMetadatais enough; on a multi-locale store it must point at the LOCALE's own URL (/{locale}/products/{localeSlug}), not at the default-locale one, or you tell Google the translated pages are duplicates of each other. - ⛔ The merchant's dashboard AUDITS your live storefront for everything in this section. Marketing → SEO Autopilot → Technical fetches the real site and shows a pass/fail row per item:
sitemap(aserves-htmlresult means the app shell is being returned instead of XML),robotsAiCrawlers,llmsTxt,agentsMd,indexNowKey(key-mismatchwhen the contents differ),homepageJsonLd,productJsonLd,productCanonical,siteVerification. The two product-level rows pick a product URL out of your ownsitemap.xml: if it lists none, both reportno-product-urland are skipped rather than passed, so the sitemap bullet above is a prerequisite for them being measurable at all. The probe is https-only and never follows redirects, so a store reachable only over http or behind a redirect reads asnot-reachable. - Category pages are the highest-leverage organic surface, so build them AND link to them.
/category/[slug]viaclient.getCategoryBySlug(slug)for the metadata +getProducts({ categories: [id] })for the grid.getCategoryBySlugreturnsPromise<CategoryDetail>and throws on a miss, it does not resolve tonull— 404 it withconst category = await client.getCategoryBySlug(slug).catch(() => null); if (!category) notFound();. That's the opposite ofclient.content.page.getBySlug/client.blog.getPost, which returnPromise<X | null>and need no.catchat all — the return type in the SDK is the discriminator, not the domain. Both of those are storefront-mode reads and throw from anapiKeyclient; the admin equivalents split the same way, withblog.findById(id, storeId)resolving tonullandcontent.findById(id, storeId)throwing. RendermetaDescriptioninto the meta tag and the sanitizeddescriptionHTML below the grid. A category page with no real<a href="/category/{slug}">pointing at it from anywhere in the storefront (nav, category tiles, breadcrumbs) is effectively invisible to crawlers even if it's in the sitemap. Sitemap inclusion is not a substitute for on-page internal links. Renderproduct.categories[].slugas a real link on the product page too (e.g. in a breadcrumb), not just a filter chip. - A category needs to be published to the sales channel to appear at all.
getCategories()/getCategoryBySlug()only return categories explicitly published to the callingconnectionId, so a category can exist with a full AI-written description and still resolve to an empty tree / 404 on a specific storefront if it was never published to that channel. Publish via the dashboard's category grid (orPOST /categories/:id/publish-sales-channel) before expecting it to render. - Product + category + blog entries MUST be in
sitemap.xml, and products MUST usegetProductSitemapEntries. The public listing API clampslimitto 100, so a naivegetProducts({ limit: 1000 })sitemap silently truncates at 100 products;getProductSitemapEntries(client, { siteUrl, locales, defaultLocale })uses a dedicated lightweight endpoint (up to 5000 products in one call) and falls back to pagination on older backends. AppendgetCategorySitemapEntries(...)andgetBlogSitemapEntries(...)too. The SEO Autopilot writes category descriptions + publishes articles automatically, and a missing sitemap entry means they never get crawled. - robots.txt must ALLOW the AI search crawlers by name:
OAI-SearchBot,ChatGPT-User,Claude-SearchBot,Claude-User,PerplexityBot,Perplexity-User,Bingbot,Applebot,Amazonbot. These agents power ChatGPT/Claude/Perplexity/Copilot shopping answers, read raw HTML only, and respect robots.txt; blocking them (or fronting the store with an unconfigured bot blocker) makes the store invisible to AI assistants. Keep/api/,/auth/,/checkout/,/account/disallowed for everyone. Training bots (GPTBot,ClaudeBot,CCBot,Google-Extended,Meta-ExternalAgent) are allowed by default in the scaffold, and blocking them does not affect search visibility, so it's a merchant policy choice, not an SEO one. - Serve the IndexNow key file exactly as documented:
GET /indexnow-key.txtreturninggetStoreInfo().seo.indexNowKeyastext/plain, 404 whilenull. The platform pings IndexNow on every blog publish; the ping is silently skipped until this file is live. The key is not a secret. - Serve
/llms.txtAND/agents.md. llms.txt is the AI-answer-engine summary (store + categories + recent articles); agents.md is the agent-facing guide (machine surfaces, key URLs, how buying works). See the scaffold'sapp/llms.txt/route.tsandapp/agents.md/route.ts. Multi-locale stores: these dotted routes (plusindexnow-key.txt) must live at the app ROOT, never inside[locale]/. The locale middleware matcher skips dotted paths, so a locale-nested copy resolves as the homepage withlocale="llms.txt"and serves HTML. List the public Storefront MCP endpoint (POST /api/mcp/storefront/{salesChannelId}) inagents.mdtoo, so agents that read it know to call structured tools instead of crawling; see Storefront MCP for the endpoint and tool reference. - Render the Google site-verification meta tag when configured.
getStoreInfo().seo.googleSiteVerification(merchant-set in channel settings) →<meta name="google-site-verification" content={token} />in the root layout<head>. It's what lets the merchant verify the domain in Search Console and claim the website in Merchant Center. - Resolve renamed slugs before 404ing. The platform records every product/blog slug rename. In the not-found path of
/products/[slug]and/blog/[slug], callclient.resolveSlugRedirect('product' | 'blog', slug). On a hit,permanentRedirect()to the returnedcurrentSlug(default locale unprefixed); only anullresult falls through tonotFound(). Without this, every slug edit in the dashboard permanently 404s the old URL and its accumulated ranking. - Serve
/.well-known/ucpif the merchant uses Google cart transfer, verbatim, and 404 when absent.getStoreInfo().ucp?.profileis the document Google reads to find where to send a shopper's cart. It is a storefront route, not an SDK feature: abrainerceupgrade alone does not add it.application/json, HTTPS,Cache-Control: publicwithmax-ageof 60 or more, no redirect (www and the bare domain must both answer), 503 rather than 404 when the backend cannot be reached, and never edited, merged with anything, or serialised into page HTML. It lives at the app ROOT on multi-locale stores. See Google cart transfer (UCP). - Blog pages are required when the store publishes posts.
/blog(list viaclient.blog.getPosts()) and/blog/[slug](viaclient.blog.getPost(slug),null→ 404, sanitizepost.contentbefore rendering). Subscribe to theblog.post.published/blog.post.updatedwebhook events to revalidate ISR caches.