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 }))

RuleServer behavior
Definition must have filterable: trueOtherwise the key is silently ignored
Definition type must be SELECT, MULTI_SELECT, or BOOLEANOther types are silently ignored
Unknown definition keysSilently ignored
Setting filterable: true via the admin API on a non-SELECT/MULTI_SELECT/BOOLEAN typeHTTP 400: "filterable is only supported for…"
Filter values for BOOLEANSend the strings "true" / "false"
SemanticsAND 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

FieldRuleError if violated
quantity (add to cart)Must be integer >= 1HTTP 400
quantity (update)Must be integer >= 0 (0 removes item)HTTP 400
variantIdRequired for VARIABLE products, must NOT be sent for SIMPLEHTTP 400: "Variant selection is required for this product"
productIdMust 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: ..."
recommendationTokenOptional. The recToken of the recommendation card the add came from; see RecommendationsAn 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

RuleWhy
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 themA 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 absentIt 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 variantIdA 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 tokenThe 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 fetchA 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 4xxA 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. addToCart takes the kit's own productId. The server reserves each component behind that one line. Adding components individually charges the wrong total and double-reserves stock.
  • Read kitAvailable for a kit's stock, not inventory. null means unlimited, 0 means not sellable. Its stock is whichever component runs out first, so it can drop without the kit itself changing. A kit does carry an inventory block, but only the sales-channel (vc_*) reads derive it from the components; a storeId storefront gets a placeholder (trackingMode: "TRACKED", available: 0, canPurchase: false) that reads as sold out whatever the components hold. kitAvailable is right in both modes.
  • kitComponents is display only. Every sales-channel read (list, by slug, by id) returns it, and so do the storeId single-product reads; only the storeId list omits it. Render it so the shopper can see what is in the box.
  • Outside FIXED pricing, basePrice on 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:

TypeExpected value shapeExtra rules
TEXTstringRespects minLength / maxLength (characters)
TEXTAREAstringRespects minLength / maxLength (characters)
NUMBERnumberRespects minValue / maxValue
BOOLEANbooleanNone
DATEYYYY-MM-DD stringExactly this shape; must satisfy dateAvailability if set (see below)
DATETIMEISO 8601 date-timeYYYY-MM-DDTHH:mm[:ss[.sss]], optional Z/±HH:mm; must satisfy dateAvailability
URLstringMust match http(s)://…
COLORstringMust match #RRGGBB
JSONstringMust parse via JSON.parse
SELECTstringMust be one of enumValues
MULTI_SELECTstring[]Every element in enumValues; duplicates removed; minLength/maxLength = array size
IMAGEstring (asset URL)Must be the URL returned from POST /customization-upload on this store
GALLERYstring[] (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 sendResult
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

RuleValue
EndpointPOST /customization-upload?fieldKey=<field key> (multipart file field). fieldKey is optional but needed for the merchant's print-quality setting to apply
Max size10 MB and 40 megapixels
MIME allowedimage/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
Throttle10 uploads / minute per IP → HTTP 429 Too Many Requests
QualityPer 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
RetentionAt 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

FieldRuleError if violated
emailRequired, must be valid email formatHTTP 400
firstNameRequired for shipping/billing addressHTTP 400
lastNameRequired for shipping/billing addressHTTP 400
line1Required for shipping/billing addressHTTP 400
cityRequired for shipping/billing addressHTTP 400
postalCodeRequired for shipping/billing addressHTTP 400
countryRequired, must be 2-letter ISO code (e.g., "IL", "US")HTTP 400
phoneOptional but recommendedNone
notesOptional order note, max 2000 chars. Include an "Order notes" textarea by defaultHTTP 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:

FieldautocompleteAlso
emailemailtype="email", inputmode="email"
firstNamegiven-name
lastNamefamily-name
phoneteltype="tel", inputmode="tel"
line1address-line1 (off on a typeahead field)
line2address-line2
cityaddress-level2
regionaddress-level1
postalCodepostal-codeno numeric inputmode: letters occur
countrycountry

Prefix the tokens with shipping or billing when one page holds both address blocks. See Core, Checkout input attributes.

Gift cards

FieldRuleError 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 typingHTTP 400
tenderId (remove)The tenderId from checkout.tenders, never the code. A checkout can carry several cardsHTTP 404 if it is not a live tender on this checkout
Checkout stateApply and remove only work while the checkout is still editableHTTP 400 CHECKOUT_LOCKED once payment is in progress
CurrencyA card pays only in its own currency. There is no conversionRefused 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

FieldRuleError if violated
emailRequired, valid email, unique per storeHTTP 400: "Email already exists"
passwordRequired for registration. At least 8 characters, with at least one lowercase letter, one uppercase letter, one digit, and one special characterHTTP 400: "Password must contain at least 1 uppercase letter, 1 lowercase letter, 1 number, and 1 special character"
firstNameOptionalNone
lastNameOptionalNone

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.errors

Switch 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 codeMessageWhat 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.

codeWhat happenedWhat to show
COUPON_NOT_FOUNDNo 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_ACTIVEstartsAt is in the future"This coupon isn't active yet"
COUPON_EXPIREDendsAt has passed"This coupon has expired"
COUPON_USAGE_LIMIT_REACHEDStore-wide usageLimit exhausted"This coupon is no longer available"
COUPON_PER_CUSTOMER_LIMIT_REACHEDThis customer already used it the allowed number of times"You have already used this coupon"
COUPON_MINIMUM_NOT_METCart subtotal is below the coupon minimum (the message names the amount)Show the required amount, keep the code in the input
COUPON_REGION_RESTRICTEDThe coupon is not valid in the shopper's region"This coupon is not available in your region"
COUPON_NO_ELIGIBLE_ITEMSNo 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:

messageWhat happenedWhat to show
Coupon can only be applied to an active checkoutCheckout is past the editable stage; payment startedHide the coupon input once payment begins
Coupons cannot be applied to partial checkoutsCheckout was created with selectedItemIdsHide 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. details is the slot that survives.

message is a generic, non-specific sentence by design: it describes the class of failure, not which option was wrong. Branch on code and render your own copy from details.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].codeWhen it firesWhat to do
REQUIRED_GROUP_MISSINGA required: true group has no selectionHighlight the group, show "Please pick at least one"
MIN_SELECTIONS_NOT_METFewer picks than min (meta: min, current)Show "Pick at least N"
MAX_SELECTIONS_EXCEEDEDMore 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_SELECTIONSMore than one modifier sent for a SINGLE group (meta: current)Bug in your renderer. Re-render as radio
MODIFIER_NOT_IN_GROUPA modifierId does not belong to the named groupRefetch the product and re-render; the catalog moved under you
UNKNOWN_GROUP_IN_SELECTIONA modifierGroupId is not attached to this product / variantSame again: refetch the product
MODIFIER_NOT_ACTIVEThe picked modifier has been archived by the merchantRefetch the product; the option should be gone
MODIFIER_UNAVAILABLEThe picked modifier is sold out right now (available: false)Refetch the product, ask the customer to re-pick
MODIFIER_DISABLED_FOR_VARIANTThe picked modifier is disabled for the chosen variantRefetch the product for this variant; the option should be absent
MODIFIER_NESTING_TOO_DEEPA 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 codeBug 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 codeMessageCarriesWhat to do
INSUFFICIENT_STOCK"Some items are out of stock"details.items[] with productId, variantId, available, requestedMark 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, variantIdRemove 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, directionShow 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 codeWhenWhat to do
PICKUP_POINT_REQUIREDcreatePaymentIntent or completeCheckout while that rate is selected and no point is chosenSend the shopper to the pickup point step (Step 4.6b). Keep the pay button disabled until checkout.pickupPoint is set
PICKUP_POINT_NOT_APPLICABLEselectPickupPoint while the selected rate does not deliver to a pickup pointSelect 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:

StatusmessageWhat to do
400Cannot create checkout from empty cartSend the shopper back to the cart page
400Checkout session has expiredCreate a new checkout from the same cart; the cart is still valid
400Shipping address must be set firstShow the shipping-address form; you called a later step out of order
400Customer email is requiredShow the customer-info step first
404Shipping rate not foundRe-fetch shipping rates
404Shipping rate not found or no longer available for this addressThe 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"
}
RuleWhy
Show the message as-isIt 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 checkoutThe 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 loopIt 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 reasonThe 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:

StatusmessageWhat happenedWhat to do
400This order is already fully covered.Cards on the checkout already cover the total, so there is nothing left for another card to payHide the gift-card field and show the "amount due 0" state
400Cannot modify checkout while payment is being processed (code: "CHECKOUT_LOCKED")Apply / remove after payment startedHide the gift-card field once you create the payment intent
404No such tender on this checkoutThe tenderId is not a live tender here — usually a stale one you cachedRe-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 expiresAt field 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 generic code: "BAD_REQUEST". It is not a 410, and there is no CHECKOUT_EXPIRED code, 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 expiresAt so 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:

TierDefault limitWindow
Short60 requests1 minute
Long1000 requests1 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 ceiling
  • X-RateLimit-Remaining / X-RateLimit-Remaining-long: requests left in that window
  • X-RateLimit-Reset / X-RateLimit-Reset-long: seconds until that window resets
  • The same values also arrive as the standard RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset and RateLimit-Policy (the bucket closest to exhaustion), and Retry-After on a 429. 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 Origin header, and it is not treated as "no origin to check". Mismatch is rejected with 403 Origin not allowed; a missing header is rejected earlier, by the guard described above, with 403 Origin header required. Server-side rendering and Route Handlers send no Origin by 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 with 403 Connection missing domain.
  • The Origin host must equal the domain's host, or be a subdomain of it: shop.example.com matches example.com. Comparison is case-insensitive, a leading www. is ignored on both sides, and the port is significant, so http://localhost:5173 does not match http://localhost:3000. The scheme is not compared.
  • An origin that is not on the domain is 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 the domain (a LIVE channel with no domain is 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 the domain (or a subdomain). See Social login.
  • Error on mismatch: 403 Origin not allowed.

TEST channels

  • With no domain set, any Origin is 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 a domain, or switch to LIVE.
  • With a domain set, a request that carries an Origin is 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

ScenarioBehavior
Same browser, multiple tabsTabs share the same cart (same sessionToken in localStorage)
Different browsers or devices (guest)Each has its own cart (different sessionToken)
Logged-in customer, any deviceAll share the same cart (linked to customerId)
Guest logs inMust explicitly merge carts (Task 8, step 8.3). Not automatic.
Two tabs complete same checkoutFirst succeeds, second gets same order (idempotent). No duplicate orders.
Two tabs add same itemBoth succeed, quantity increases

Idempotency

These operations are safe to retry:

OperationRetry behavior
completeCheckoutReturns 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 completeBoth 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:

ModeCapped?
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(), never parseInt()
  • Format using Intl.NumberFormat with 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 calls session.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 on e.code. session_locked → offer "Start a new checkout" (go to /checkout without checkout_id); load_failed with e.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-complete calls relayPaymentReturn() and renders nothing else; /order-confirmation calls client.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 with extraPaymentHosts / extraScriptHosts.
  • CSP: build it with buildStorefrontCsp. Its frame-src covers every payment host and your store's apiOrigin.

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 paymentUrl field. Earlier revisions of this page described a hosted-page branch keyed on paymentIntent.paymentUrl. No such field is ever returned, so the branch was dead. The URL a hosted-page provider wants you to open arrives in clientSdk.renderArg, falling back to clientSecret, and which surface to open it in is told by clientSdk.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.renderTypeWhat you readHow to handle
'sandbox'nothingNo payment UI. Call completeCheckout directly.
'sdk-widget'renderArg || clientSecret as the render argument, plus initConfigThe 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 URLLoad 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 URLNavigate 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) from confirmSdkPayment() is not a failure. Some providers answer processing for minutes while they settle. The payment is left untouched, getPaymentStatus() keeps returning pending until the provider's webhook settles it, and only failed or canceled mean "try again". Keep polling; do not call createPaymentIntent again because of it (a second intent lets the buyer pay twice). startPayment() already behaves this way and ends in the unconfirmed error code while the order is still on its way.

confirmSdkPayment() and getPaymentStatus() are sales-channel mode only. Both throw BrainerceError 400 ("… is only available in sales-channel mode") on a client built with storeId or apiKey. Build the payment return page against a salesChannelId client.


Required pages

Your website MUST have these pages/routes for the integration to work:

PagePurposeWhen needed
Product listingDisplay products from BrainerceAlways
Product detailSingle product page with variant selectionAlways
CartView and manage cart itemsAlways
CheckoutMulti-step checkout formAlways
Order confirmation / return URLShow 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 elseAlways
Order lookupLet guests check order status by email + order numberRecommended
Login / RegisterCustomer authenticationIf implementing Task 8
Password resetReset 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 newerOptional (wishlist)
Auth callbackHandle 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 consumesIf implementing Task 10

The order confirmation page is critical. It must:

  1. Read checkout_id from the URL
  2. Call client.completePaymentReturn(checkoutId) (without the SDK: sdk-confirm once, then poll payment status and call completeCheckout if the order is not yet created)
  3. On succeeded: clear the cart and all localStorage keys, show the order number
  4. On pending: show "We are confirming your payment, your order will appear shortly"
  5. 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 confirmation

Guest 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 + orderNumber

Payment 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.providerAmountDue

A 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.

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.

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, or client.wishlist.local.add() for a signed-in customer. The SDK does not decide which list applies: your code picks local for a guest and the server methods for a customer. A guest's add() 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 inventing addToWishlist / getWishlist / a /wishlist REST 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 with resolve(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() returned ok
  • Guest cart writes carry cartToken (automatic with the SDK; your own helper if raw REST) and 403 CART_ACCESS_DENIED does not log the shopper out
  • Floating widgets (popup, chat bubble, cookie banner, accessibility button) are hidden on /cart and /checkout and 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 (fra1 for Europe), and cartToken is redacted in proxy and function logs

Checkout

  • Checkout creates from cart
  • Checkout inputs carry autocomplete tokens, a name, and inputmode for 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 total never modified
  • Applied cards read from checkout.tenders on 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, calls completeCheckout directly, 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; no renderType or provider-name branching, no provider globals, no hand-built payment iframe
  • provider-page in a modal, embedded / widget inline, sandbox shows "Complete test order" (session.completeTestOrder())
  • onError handles session_locked, load_failed (with e.url), unconfirmed
  • session.destroy() on unmount
  • /payment-complete calls relayPaymentReturn() and renders nothing else
  • /order-confirmation calls client.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_id from 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_PAUSED from createPaymentIntent: message shown, cart kept, no retry loop
  • createPaymentIntent / startPayment send expectedTotal; a 409 CHECKOUT_TOTAL_CHANGED shows the new total and waits for the buyer to accept before retrying
  • Shipping form collects region (state / province) for US, CA and AU; a 400 TAX_ADDRESS_REGION_REQUIRED asks 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 answers 403 with details.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 submitProductReview returns 403 Forbidden with reason: 'no_eligible_order'.
    • Physical products → order status SHIPPED / COMPLETED / DELIVERED.
    • Downloadable products → additionally PAID / PROCESSING.
  • One review per customer per product. Duplicate submits return 409 Conflict. Use updateMyProductReview to edit, deleteMyProductReview to start over.
  • No author name/email in the body. Both are derived server-side from the customer profile. Pass only { rating, body? }.
  • rating must be an integer 1-5 (DB CHECK constraint rejects anything else).
  • body is 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.
  • verifiedPurchase is always true for customer-submitted reviews (since they must purchase). External-api imports (Yotpo/Judge.me) create reviews with verifiedPurchase: false and supply author info directly.
  • hiddenAt is set only by merchants via the admin surface. Storefront list endpoint never returns hidden reviews.
  • Emit aggregateRating in Product JSON-LD only when reviewCount > 0 and getStoreInfo().reviewsEnabled !== false. Do not invent values. A product keeps its stored avgRating and reviewCount while 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 = false answer every storefront review route (list, submit, edit, delete, photo upload) with 403 and details.code === 'REVIEWS_DISABLED'. Read getStoreInfo().reviewsEnabled first and render nothing when it is false: no reviews section, no review form, no star rating on product cards, no aggregateRating. Treat an absent flag (older backend) as on and fall back to catching the REVIEWS_DISABLED 403. 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".
  • getMyProductReview is the one exception: it does not throw while reviews are off, it answers 200 with eligible: false and reason: 'reviews_disabled' (lowercase, a reason string, not the error code).
  • 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. imageKeys accepts the key from uploadReviewPhoto(). 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. uploadReviewPhoto requires a customer JWT and an eligible order, checked before the bytes are stored. This is stricter than uploadCustomizationFile, which is anonymous by design.
  • imageKeys replaces 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.requiresApproval and tell the customer.
  • On an approval-required store the submit/update response's images is empty, because it carries only shopper-visible photos. Re-fetch getMyProductReview() and render myImages so the customer sees their pending upload.
  • listProductReviews defaults to sort: 'photos_first'. Pass sort: 'newest' for chronological order.
  • review.images is always an array. Render img.width / img.height on the <img> to avoid layout shift.

Content (typed merchant content)

  • Sanitize before rendering. FAQ.items[i].answer, RICH_TEXT.html, PAGE.html, and Product.description are merchant-authored HTML. The server does NOT pre-sanitize FAQ/RICH_TEXT/PAGE. ALWAYS pipe through isomorphic-dompurify (or equivalent) before dangerouslySetInnerHTML. Skipping this is XSS. Product.description may also contain <video> and host-locked YouTube/Vimeo <iframe> embeds, so your sanitizer must allow those tags (iframe restricted to www.youtube.com / www.youtube-nocookie.com / player.vimeo.com) and your CSP frame-src must list those hosts. See Core Integration → Product descriptions can contain video and embeds.
  • get() / getBySlug() return null on 404. Render a hard-coded fallback when null so 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 to key='main'. Don't ship topical entries under 'main'.
  • key is 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) carry Cache-Control: public, max-age=60, stale-while-revalidate=300 with Vary: Origin, Accept-Language, X-Brainerce-Features, Authorization. Only a successful answer carries it, and products, prices, stock, carts, orders, customers and /regions/auto carry none. Browsers honour it; a server does not (Next.js server fetch is 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: false turns it off, publicCache: { ttlMs } tunes it, and client.clearPublicCache() (for example from a content.* 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 (with itemId) 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: forward Retry-After on a 429 (the SDK's automatic retry needs it), and answer anonymous reads from the same allowlist with isCacheablePublicRead(path) and readThroughPublicCache(...) (both exported from brainerce), never for a request that carries the customer token and never storing a header.
  • Locale resolution is server-side. Pass locale to get / list / getBySlug, and the server runs deep-merge with empty-string fallthrough and returns the resolved data. Do NOT overlay translations client-side.
  • Locale fallback rules: missing locale / null / === default → returns base data unchanged. translations[locale] missing → returns base unchanged. Otherwise deep-merge (arrays merge by index).
  • Custom fields are merchant-defined. row.customFields is Record<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 the salesChannelIds filter, and non-empty restrictions are only honored when the request arrives via /api/vc/:connectionId/....
  • announcement.list() returns ALL active announcements. Filter client-side by startsAt / endsAt ISO timestamps so the cached server response can be shared across visitors.
  • Persist announcement.dismissible dismissals in localStorage keyed by announcement id (not by text, which may be edited). The scaffold's <AnnouncementBar> does this for you.
  • Admin write operations require apiKey mode. client.content.faq.create() / update / publish / remove throw BrainerceError(403) when called from storefront / sales-channel mode.
  • Every admin content and blog call takes an explicit storeId as 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 matching blog.* calls. Admin mode has no ambient store, since the constructor's storeId only takes effect in storefront mode, so the SDK sends it as a query param and never as a body field. A missing storeId, or one naming a store your key is not bound to, is rejected by the store scope guard with 403 STORE_SCOPE_REQUIRED before the handler runs, so do not write a handler for a 400. Scopes: content:read / content:write, and blog:read / blog:write.
  • The public reads throw in admin mode. content.<type>.get(), content.<type>.list(), content.page.getBySlug() and blog.getPost(slug) are storefront APIs. From an apiKey client they throw rather than issue a request that could only fail, because the admin API has no by-key or by-slug read. Use content.listAdmin({ storeId, type }) / content.findById(id, storeId), or blog.getPosts({}, storeId) / blog.findById(id, storeId).
  • The two admin findById methods disagree on 404. blog.findById(id, storeId) resolves to null; 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 after create() without user confirmation. The dashboard enforces this; respect the same boundary in admin scripts.
  • update() replaces data wholesale. 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. Prefer unpublish() 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 + jsonLdScriptProps encode 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 includes itemCondition (hardcoded NewCondition), priceValidUntil when the product has an active sale-price window (salePriceEndsAt), and shippingDetails when you pass shipping: 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 via sameAs social profiles) + buildWebsiteJsonLd with searchUrlTemplate pointing 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 + buildBreadcrumbJsonLd instead.
  • Every product page needs a <link rel="canonical">. Next's alternates.canonical in generateMetadata is 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 (a serves-html result means the app shell is being returned instead of XML), robotsAiCrawlers, llmsTxt, agentsMd, indexNowKey (key-mismatch when the contents differ), homepageJsonLd, productJsonLd, productCanonical, siteVerification. The two product-level rows pick a product URL out of your own sitemap.xml: if it lists none, both report no-product-url and 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 as not-reachable.
  • Category pages are the highest-leverage organic surface, so build them AND link to them. /category/[slug] via client.getCategoryBySlug(slug) for the metadata + getProducts({ categories: [id] }) for the grid. getCategoryBySlug returns Promise<CategoryDetail> and throws on a miss, it does not resolve to null — 404 it with const category = await client.getCategoryBySlug(slug).catch(() => null); if (!category) notFound();. That's the opposite of client.content.page.getBySlug / client.blog.getPost, which return Promise<X | null> and need no .catch at all — the return type in the SDK is the discriminator, not the domain. Both of those are storefront-mode reads and throw from an apiKey client; the admin equivalents split the same way, with blog.findById(id, storeId) resolving to null and content.findById(id, storeId) throwing. Render metaDescription into the meta tag and the sanitized description HTML 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. Render product.categories[].slug as 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 calling connectionId, 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 (or POST /categories/:id/publish-sales-channel) before expecting it to render.
  • Product + category + blog entries MUST be in sitemap.xml, and products MUST use getProductSitemapEntries. The public listing API clamps limit to 100, so a naive getProducts({ 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. Append getCategorySitemapEntries(...) and getBlogSitemapEntries(...) 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.txt returning getStoreInfo().seo.indexNowKey as text/plain, 404 while null. 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.txt AND /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's app/llms.txt/route.ts and app/agents.md/route.ts. Multi-locale stores: these dotted routes (plus indexnow-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 with locale="llms.txt" and serves HTML. List the public Storefront MCP endpoint (POST /api/mcp/storefront/{salesChannelId}) in agents.md too, 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], call client.resolveSlugRedirect('product' | 'blog', slug). On a hit, permanentRedirect() to the returned currentSlug (default locale unprefixed); only a null result falls through to notFound(). Without this, every slug edit in the dashboard permanently 404s the old URL and its accumulated ranking.
  • Serve /.well-known/ucp if the merchant uses Google cart transfer, verbatim, and 404 when absent. getStoreInfo().ucp?.profile is the document Google reads to find where to send a shopper's cart. It is a storefront route, not an SDK feature: a brainerce upgrade alone does not add it. application/json, HTTPS, Cache-Control: public with max-age of 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 via client.blog.getPosts()) and /blog/[slug] (via client.blog.getPost(slug), null → 404, sanitize post.content before rendering). Subscribe to the blog.post.published / blog.post.updated webhook events to revalidate ISR caches.