Rules & Reference
Validation rules, error codes, edge cases, decision trees. The lookup reference when something breaks.
This is Part 3 of the integration guide.
- Part 1 (REQUIRED): Core Integration. Core storefront: products, cart, checkout, payment, orders
- Part 2 (OPTIONAL): Optional Features. Customer accounts, social login, promotions, upsells, downloads
- Part 3 (REFERENCE): This file. Validation rules, error codes, edge cases, decision trees, common mistakes
Read this file when:
- You get an API error you don't understand
- You need to handle edge cases (expiration, rate limiting, multi-tab)
- You want to verify you're handling all error codes
- You need the decision tree for a conditional flow
- You want to check your work against the common mistakes list
Validation rules
Custom-field filters (getProducts({ metafields }))
| Rule | Server behavior |
|---|---|
Definition must have filterable: true | Otherwise the key is silently ignored |
Definition type must be SELECT, MULTI_SELECT, or BOOLEAN | Other types are silently ignored |
| Unknown definition keys | Silently ignored |
Setting filterable: true via the admin API on a non-SELECT/MULTI_SELECT/BOOLEAN type | HTTP 400: "filterable is only supported for…" |
Filter values for BOOLEAN | Send the strings "true" / "false" |
| Semantics | AND across keys, OR within a key |
Facet value counts: GET /metafield-filters (SDK getMetafieldFilters())
returns one entry per filterable definition with distinct-active-product counts
per value. MULTI_SELECT arrays are split per element, BOOLEAN buckets are
"true" / "false", and zero-count enumValues entries are included. See
Part 1, Step 2.4a.
Cart
| Field | Rule | Error if violated |
|---|---|---|
quantity (add to cart) | Must be integer >= 1 | HTTP 400 |
quantity (update) | Must be integer >= 0 (0 removes item) | HTTP 400 |
variantId | Required for VARIABLE products, must NOT be sent for SIMPLE | HTTP 400: "Variant is required for variable products" |
productId | Must exist and be published to this connection | HTTP 404 |
metadata.<field> | If product has customizationFields, keys match field.key; shape validated per type (see below) | HTTP 400: "Invalid customization input: ..." |
Kits (type: 'KIT')
A kit is one purchasable product assembled from other catalog products. It is bought as a SINGLE cart line.
- Add the kit, never its components.
addToCarttakes the kit's ownproductId. The server reserves each component behind that one line. Adding components individually charges the wrong total and double-reserves stock. - A kit has no
inventoryblock. ReadkitAvailableinstead:nullmeans unlimited,0means not sellable. Its stock is whichever component runs out first, so it can drop without the kit itself changing. kitComponentsis display only, and is returned on the single-product (by slug) read, not on list responses. Render it so the shopper can see what is in the box.- Outside
FIXEDpricing,basePriceon the raw product row is a placeholder. The by-slug read returns the resolved price; use that. Checkout recomputes it authoritatively and snapshots the result onto the cart line. - A kit cannot contain another kit, and a component that has variants must have one pinned.
Customization fields (buyer input on product page)
When a product returns customizationFields, any values the buyer fills must be sent on POST /cart/:id/items as metadata. Server-enforced rules per type:
| Type | Expected value shape | Extra rules |
|---|---|---|
TEXT | string | Respects minLength / maxLength (characters) |
TEXTAREA | string | Respects minLength / maxLength (characters) |
NUMBER | number | Respects minValue / maxValue |
BOOLEAN | boolean | None |
DATE | YYYY-MM-DD string | Exactly this shape; must satisfy dateAvailability if set (see below) |
DATETIME | ISO 8601 date-time | YYYY-MM-DDTHH:mm[:ss[.sss]], optional Z/±HH:mm; must satisfy dateAvailability |
URL | string | Must match http(s)://… |
COLOR | string | Must match #RRGGBB |
JSON | string | Must parse via JSON.parse |
SELECT | string | Must be one of enumValues |
MULTI_SELECT | string[] | Every element in enumValues; duplicates removed; minLength/maxLength = array size |
IMAGE | string (asset URL) | Must be the URL returned from POST /customization-upload on this store |
GALLERY | string[] (asset URLs) | Every element must be an upload URL on this store |
Fields marked required: true must be present and non-empty. Unknown keys (not declared on the product) are accepted silently but not snapshotted onto the order line, so only declared fields survive checkout.
dateAvailability (DATE/DATETIME only). A field may restrict which dates/times are valid: minDate/maxDate, blockedWeekdays (0=Sun..6=Sat), blockedDates, the relative bounds leadTimeMinutes / cutoffTime / maxDaysAhead, and, for DATETIME only, businessHours (per-weekday open/close, "HH:mm") + slotDurationMinutes. The server rejects any submitted value outside these bounds with HTTP 400, evaluated in the store's timezone (getStoreInfo().timezone), not the buyer's. Use computeAvailableSlots() / getBusinessHoursForDate() / isDateValueAllowed() (exported from brainerce) to pre-validate client-side and avoid a round trip.
The relative bounds are checkout-only. leadTimeMinutes, cutoffTime and maxDaysAhead are accepted on checkout custom fields and rejected on product metafields and order custom fields, because those two are written by an admin, often long after the order exists, and there is no ordering moment for a lead time to be measured from. minDate, maxDate, blockedWeekdays and blockedDates remain available on all three.
The relative bounds move on their own; minDate/maxDate do not. leadTimeMinutes puts the floor at now + leadTime, so it expresses preparation time. cutoffTime ("HH:mm", store-local) pushes that floor on by a further day once the store clock reaches it, which is how "order by 14:00 for tomorrow" is written. maxDaysAhead is a rolling ceiling counted from today. All three are re-resolved on every request, so unlike an absolute minDate they never go stale, and all three apply to DATE fields just as much as to DATETIME ones. Both may be set at once: whichever floor falls later, and whichever ceiling falls earlier, is the one that applies.
Client-side, the relative bounds need a clock or they are skipped. isCalendarDateAllowed(), computeAvailableSlots() and getBusinessHoursForDate() take an optional last argument { timezone, now? }; isDateValueAllowed() takes an optional 5th argument now. Leave the clock out and only the absolute rules are applied, so your picker offers more dates than the server will accept, never fewer. The server always supplies one, so this is a UX gap and never a way in.
A weekday may carry several businessHours windows. A split day, 09:00 to 13:00 and again 16:00 to 20:00, is two entries sharing one weekday. Two windows on the same weekday must not overlap; an overlap is rejected when the definition is saved. computeAvailableSlots() returns the slots of every window on that day in chronological order regardless of the order they were authored in, and a submitted value must land inside one of them.
A weekday with no businessHours window is closed all day. Once businessHours contains even one entry, every weekday it doesn't mention is rejected with no business hours are configured for this day, because the array is an allowlist, not a set of exceptions. getBusinessHoursForDate() returns [] for those days; use it to grey them out in the picker.
computeAvailableSlots() empty ≠ day closed. It returns [] whenever slotDurationMinutes is unset, even on a fully open day. Call getBusinessHoursForDate() to tell the two apart: windows present + no slots means "render a free time input bounded by open/close" (the server accepts any time in the half-open interval [open, close)); no windows means the day really is closed. On the first bookable day a leadTimeMinutes narrows that interval further, and getBusinessHoursForDate() deliberately does not clamp the window it returns, so bound your input by the window and then call isDateValueAllowed() before you submit.
DATE/DATETIME value format
Parsing is strict. new Date()'s permissiveness is deliberately not the contract, because a value that parses into the wrong instant is worse than one that's rejected.
| You send | Result |
|---|---|
2026-08-13 | ✅ DATE: that calendar day. DATETIME: midnight, store-local |
2026-08-13T13:00 | ✅ 13:00 in the store's timezone. No offset means store-local, not UTC |
2026-08-13T13:00:00+03:00 | ✅ that exact instant |
2026-08-13T13:00:00Z | ✅ that exact instant |
2026-08-13T13:00:00.123456Z | ✅ 1 to 9 fractional digits accepted (Python/Java/Go output), truncated to ms |
2026-08-13T13:00-14:00 | ❌ 400: -14:00 is not a real UTC offset. A slot label is not an offset |
2026-08-13T9:30 | ❌ 400: the hour must be two digits |
2026-02-30 | ❌ 400: not a real calendar date |
Building a value as `${date}T${slotLabel}` is the mistake this table exists for: "2026-08-13" + "T13:00-14:00" used to parse as a legal instant 14 hours away and book the wrong day silently. Send the date and the time as one ISO-8601 value, or send YYYY-MM-DDTHH:mm and let the store's timezone apply.
What comes back is normalized, not what you sent. The stored and returned value is YYYY-MM-DD for DATE and an ISO-8601 UTC instant for DATETIME, regardless of which accepted form you submitted. Don't round-trip the raw string and expect it back verbatim.
Apply-to-all fields. If a field's underlying MetafieldDefinition has appliesToAllProducts: true, the backend folds it into every product's customizationFields array, including products created after the flag was set. Validation treats those entries identically to per-product assignments. Your client code reads product.customizationFields as-is and never needs to merge definitions manually.
Order snapshot. After checkout, each order.items[i].customizations captures { label, value, type } per submitted field. The snapshot is permanent, so editing or deleting the definition afterwards does not change existing orders.
Customization uploads
| Rule | Value |
|---|---|
| Endpoint | POST /customization-upload (multipart file field) |
| Max size | 5 MB |
| MIME allowed | image/* only (jpeg/png/webp/gif). Others → HTTP 400 |
| Throttle | 10 uploads / minute per IP → HTTP 429 Too Many Requests |
| Retention | At least 7 days. Deleted after 7 days if the cart isn't ordered. |
| Response | { url: string, key: string, size: number, mimeType: string } |
Checkout
| Field | Rule | Error if violated |
|---|---|---|
email | Required, must be valid email format | HTTP 400 |
firstName | Required for shipping/billing address | HTTP 400 |
lastName | Required for shipping/billing address | HTTP 400 |
line1 | Required for shipping/billing address | HTTP 400 |
city | Required for shipping/billing address | HTTP 400 |
postalCode | Required for shipping/billing address | HTTP 400 |
country | Required, must be 2-letter ISO code (e.g., "IL", "US") | HTTP 400 |
phone | Optional but recommended | None |
notes | Optional order note, max 2000 chars. Include an "Order notes" textarea by default | HTTP 400 if > 2000 chars |
Gift cards
| Field | Rule | Error if violated |
|---|---|---|
code (apply / balance) | Required string, 4-64 characters. Case, spaces and dashes are all tolerated, and the letters excluded from the code alphabet (I, L, O, U) are corrected to the characters they are mistaken for, so you do not need to normalize the shopper's typing | HTTP 400 |
tenderId (remove) | The tenderId from checkout.tenders, never the code. A checkout can carry several cards | HTTP 404 if it is not a live tender on this checkout |
| Checkout state | Apply and remove only work while the checkout is still editable | HTTP 400 CHECKOUT_LOCKED once payment is in progress |
| Currency | A card pays only in its own currency. There is no conversion | Refused like any other unusable code |
A gift card is a means of payment, not a discount. Applying one does not
change checkout.total, and tax stays calculated on the full order value. What
drops is checkout.providerAmountDue — what the payment provider will be
charged. Render the card on its own line below the total, then an "Amount
due" line; never inside the discount block and never added to discountAmount.
Folding stored value into a discount understates the taxable base to the shopper
and on their receipt.
Customer
| Field | Rule | Error if violated |
|---|---|---|
email | Required, valid email, unique per store | HTTP 400: "Email already exists" |
password | Required for registration. At least 8 characters, with at least one lowercase letter, one uppercase letter, one digit, and one special character | HTTP 400: "Password must contain at least 1 uppercase letter, 1 lowercase letter, 1 number, and 1 special character" |
firstName | Optional | None |
lastName | Optional | None |
Password strength (registration AND reset)
The same rule is enforced on registerCustomer() and on resetPassword(). The server-side pattern is:
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z0-9]).{8,}$/securepassword123 and newSecurePassword123 both fail: the first has no uppercase and no special character, the second has no special character. SecurePass123! passes. A form that only advertises "min 8 characters" produces a 400 the shopper cannot explain, so mirror the full rule in your own client-side validation and in the field's helper text, and render the server's message verbatim when it comes back.
Error codes
Every error body carries a code. Some endpoints set a specific code you can switch on; the rest fall back to a generic code derived from the HTTP status. Knowing which is which is the difference between a working error branch and a dead one.
Every error response has this shape:
{
"statusCode": 400,
"code": "INSUFFICIENT_STOCK",
"message": "Only 2 items available",
"timestamp": "2026-08-23T10:04:11.512Z",
"path": "/api/stores/store_xyz/cart/cart_abc/items"
}When no specific code was set, code is the status fallback: BAD_REQUEST (400), UNAUTHORIZED (401), FORBIDDEN (403), NOT_FOUND (404), CONFLICT (409), RATE_LIMITED (429), PAYLOAD_TOO_LARGE (413), INTERNAL_SERVER_ERROR (5xx). 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.
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; there is no
err.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;
err.items;
err.details.errors; // ← one level too shallow; it is err.details.details.errorsSwitch on err.details?.code and render a message you control. Still guard
details with ?., because errors that have no structured context omit the
block entirely.
Cart errors
These carry a specific code. Switch on it:
| Error code | Message | What to do |
|---|---|---|
PRODUCT_UNAVAILABLE | "This product is currently unavailable for purchase" | Remove item from UI, show "Product unavailable" |
INSUFFICIENT_STOCK | "Only {available} items available" | Read details.available and details.requested, then clamp the quantity input to details.available. Never parse the number out of message |
COUPON_PER_CUSTOMER_LIMIT_REACHED | "You have already used this coupon the maximum number of times." | Show "You have already used this coupon the maximum number of times" |
MODIFIER_VALIDATION_FAILED | "One or more selected options are not valid for this product" | See Modifier validation errors below |
Coupon rejections have NO specific code, so match on message
Every other applyCoupon failure is a plain HTTP 400 whose code is the generic
BAD_REQUEST. There is no INVALID_COUPON_CODE, COUPON_EXPIRED,
COUPON_NOT_ACTIVE, COUPON_USAGE_LIMIT_REACHED, COUPON_NOT_PUBLISHED,
MINIMUM_ORDER_AMOUNT, PARTIAL_CHECKOUT_NO_COUPON or CHECKOUT_NOT_EDITABLE
code on the wire, so a switch on those names never fires. (COUPON_EXPIRED,
COUPON_NOT_ACTIVE and COUPON_USAGE_LIMIT_REACHED are reserved in the
platform's code registry but no coupon path emits them today.)
Handle them by exact message, and keep a default branch so an unmatched message still shows something:
message | What happened | What to show |
|---|---|---|
Invalid coupon code | No coupon with that code on this store | "That code isn't valid" |
Coupon is not available for this store | Coupon exists but is not published to this channel | "That code isn't valid". Do not reveal that it exists |
Coupon is not yet active | startsAt is in the future | "This coupon isn't active yet" |
Coupon has expired | endsAt has passed | "This coupon has expired" |
Coupon usage limit reached | Store-wide usageLimit exhausted | "This coupon is no longer available" |
Minimum order amount of {amount} required | Cart subtotal below the coupon minimum | Show the required amount, keep the code in the input |
Coupon can only be applied to an active checkout | Checkout is past the editable stage; payment started | Hide the coupon input once payment begins |
Coupons cannot be applied to partial checkouts | Checkout was created with selectedItemIds | Hide the coupon input for partial checkouts |
Because these are messages and not codes, the platform may reword them. Do not build the decision on the string. Build the decision on "the apply failed with 400", and use the message only to pick the wording you show.
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": "...", "modifierGroupId": "mg_bread" }
]
},
"timestamp": "2026-08-23T10:04:11.512Z",
"path": "/api/stores/store_xyz/cart/cart_abc/items"
}The issue list is at
details.errors, not at the top level. The error filter rebuilds the body from a fixed key set, so anything outside the envelope would be dropped.detailsis the slot that survives.
messageis a generic, non-specific sentence by design: it describes the class of failure, not which option was wrong. Branch oncodeand render your own copy fromdetails.errors. That array is where the actionable per-group detail lives.
Switch on err.details?.code === 'MODIFIER_VALIDATION_FAILED' (over raw HTTP, the body's own code), then iterate the issue list (err.details?.details?.errors ?? [] from the SDK, or body.details.errors over raw HTTP) and surface each one inline next to the relevant group / modifier. The codes below are the full set the validator can raise.
errors[i].code | When it fires | What to do |
|---|---|---|
REQUIRED_GROUP_MISSING | A required: true group has no selection | Highlight the group, show "Please pick at least one" |
MIN_SELECTIONS_NOT_MET | Fewer picks than min | Show "Pick at least N" |
MAX_SELECTIONS_EXCEEDED | More picks than max | Show "Pick at most N", and disable extra checkboxes client-side to skip the round-trip |
SINGLE_GROUP_MULTIPLE_PICKS | More than one modifier sent for a SINGLE group | Bug in your renderer. Re-render as radio |
UNKNOWN_MODIFIER | A modifierId does not belong to the named group | Refetch the product and re-render; the catalog moved under you |
UNKNOWN_GROUP | A modifierGroupId is not attached to this product / variant | Same again: refetch the product |
MODIFIER_DISABLED_FOR_VARIANT | The picked modifier is disabled for the active variant (per ModifierVariantDisable) | Refetch the product for this variant; the option should be absent |
MODIFIER_NOT_AVAILABLE | Modifier was sold-out at submit time (available: false) | Refetch the product, ask the customer to re-pick |
NESTED_DEPTH_EXCEEDED | More than 3 levels deep in nestedByModifierId | Bug in your renderer. Never go past 3 levels |
NESTED_REQUIRES_PRODUCT_REF | You sent nestedByModifierId[parentId] but the parent modifier has no referencedProductId | Drop the nested entry; that parent isn't a combo |
INVALID_PRICE_DELTA | Server-side decimal parse failed | A bug. This should not happen for SDK callers, since it's server-validated on save |
MODIFIER_PRICE_FLOOR_VIOLATED | The unit price after applying downsells would go below 0 | Show "Cannot apply more discounts on this item" and let the customer remove a downsell |
Generic 400, no internals leak.
MODIFIER_PRICE_FLOOR_VIOLATEDdeliberately reports a generic message; the merchant's pricing internals (which downsell pushed it negative, by how much) never leak to the storefront. Show a friendly user-facing message, not the raw envelope.
Disable-for-variant convention. A group with effectiveMax === 0 is silently skipped by the validator, and never fires REQUIRED_GROUP_MISSING or MAX_SELECTIONS_EXCEEDED. The storefront should match: if product.modifierGroups[i].max === 0, do not render and do not include the group in selections.
Checkout errors
Checkout reuses the same codes as the cart, and there are no *_CHECKOUT-suffixed
variants (INSUFFICIENT_STOCK_CHECKOUT, PRODUCT_UNAVAILABLE_CHECKOUT, EMPTY_CART,
CHECKOUT_EXPIRED, SHIPPING_ADDRESS_REQUIRED, CUSTOMER_EMAIL_REQUIRED and
SHIPPING_RATE_NOT_FOUND are not emitted by anything). These three are the specific
codes checkout does set:
| Error code | Message | Carries | What to do |
|---|---|---|---|
INSUFFICIENT_STOCK | "Some items are out of stock" | details.items[] with productId, variantId, available, requested | Mark exactly those lines in the cart UI and clamp each to its available. No re-fetch needed |
PRODUCT_UNAVAILABLE | "Some products are no longer available for purchase" | details.items[] with productId, variantId | Remove exactly those lines and tell the shopper which ones went away |
PRICE_DRIFT | "Prices in your cart have changed since you added items." | details.items[] with itemId, oldUnitPrice, newUnitPrice, delta, direction | Show a "prices changed" dialog listing the old and new prices, then either POST /cart/{id}/refresh-snapshots to accept them or remove the affected lines, and retry |
The response you actually receive:
{
"statusCode": 400,
"code": "INSUFFICIENT_STOCK",
"message": "Some items are out of stock",
"details": {
"items": [{ "productId": "prod_abc", "variantId": "var_1", "available": 2, "requested": 5 }]
},
"timestamp": "2026-08-23T10:04:11.512Z",
"path": "/api/stores/store_xyz/checkout/chk_abc/complete"
}details.items[] names the offending lines precisely, so you can point at them
directly instead of re-reading the whole cart and re-deriving which line broke.
From the SDK that is err.details.details.items (err.details is the whole
body). Still guard with ?. and keep a "something in your cart changed"
fallback: PRODUCT_UNAVAILABLE can also be raised for a single deleted variant,
and a future call site may omit the block.
The rest of the checkout failures are plain 400s and 404s, so match on message
code is the generic BAD_REQUEST / NOT_FOUND for these, so branch on the HTTP status
and use the message only to choose your wording:
| Status | message | What to do |
|---|---|---|
| 400 | Cannot create checkout from empty cart | Send the shopper back to the cart page |
| 400 | Checkout session has expired | Create a new checkout from the same cart; the cart is still valid |
| 400 | Shipping address must be set first | Show the shipping-address form; you called a later step out of order |
| 400 | Customer email is required | Show the customer-info step first |
| 404 | Shipping rate not found | Re-fetch shipping rates |
| 404 | Shipping rate not found or no longer available for this address | The rate exists but is no longer eligible for this address. Re-fetch rates and make the shopper re-pick |
Payments are paused during a store ownership transfer (503 PAYMENTS_PAUSED)
POST /payment/intent (SDK createPaymentIntent) refuses with HTTP 503 and a
specific code while the store is changing hands. The platform pauses payments the
moment the buyer of a store accepts the transfer, and lifts the pause only when the
new owner connects a payment provider whose connection test passes. Nothing the
storefront does can end it, and it has lasted weeks on a real store.
{
"statusCode": 503,
"code": "PAYMENTS_PAUSED",
"message": "Payments are temporarily unavailable for this store",
"details": { "reason": "ownership_transfer" },
"timestamp": "2026-09-11T10:04:11.512Z",
"path": "/api/stores/store_xyz/payment/intent"
}| Rule | Why |
|---|---|
Show the message as-is | It is the truth: the shop cannot take money right now. Do not translate it into "try again in a moment", because a retry will not help |
| Keep the cart and checkout | The cart is valid and the checkout is not expired. Do not clear either, and do not create a new checkout; the shopper's work is not lost |
| Do not retry in a loop | It is not transient. Poll, back off, or re-create the intent and you get the same 503 until a human acts in the dashboard. One attempt per tap |
Do not branch on reason | The error body carries no reason field on the wire (the envelope drops it). code is the whole signal |
Every charge path is gated the same way (donations included), and the pause is
independent of the provider you asked for: a providerId for PayPal fails exactly
like the default card processor. The one exemption is the sandbox provider on a
TEST channel with sandbox payments enabled, so a test checkout completing is
not evidence that live payments work.
Gift card refusals are all the same answer, on purpose
POST /checkout/{id}/gift-card refuses with a plain HTTP 400, generic
code: "BAD_REQUEST", and one message for every reason:
That gift card code cannot be used on this order.An unknown code, an expired card, a spent one, a disabled one and one in the wrong currency are indistinguishable on the wire. That is deliberate: a gift card code is bearer value, so a response that told them apart would be a free oracle for discovering which codes are real. The specific reason is logged server-side and never returned.
So do not build UI that tries to explain which it was. Show one message —
"We can't use this code" — and let the shopper re-enter it. There is no
GIFT_CARD_EXPIRED, GIFT_CARD_NOT_FOUND or INSUFFICIENT_BALANCE code on the
wire; a switch on those names never fires.
POST /gift-cards/balance behaves the same way and does not error at all: an
unknown, disabled or expired code returns { balance: "0.00", usable: false },
byte-identical to each other and in the same amount of time.
The other two failures on these routes are ordinary and safe to explain:
| Status | message | What happened | What to do |
|---|---|---|---|
| 400 | This order is already fully covered. | Cards on the checkout already cover the total, so there is nothing left for another card to pay | Hide the gift-card field and show the "amount due 0" state |
| 400 | Cannot modify checkout while payment is being processed (code: "CHECKOUT_LOCKED") | Apply / remove after payment started | Hide the gift-card field once you create the payment intent |
| 404 | No such tender on this checkout | The tenderId is not a live tender here — usually a stale one you cached | Re-read the checkout and render checkout.tenders again |
Address autocomplete failures (non-fatal, never block checkout on these)
getAddressSuggestions() / getAddressDetails() (see Core Integration Step 4.5)
can fail for reasons unrelated to the shopper's input: the store hasn't
configured GOOGLE_PLACES_API_KEY yet, a transient upstream error, or a
q under 3 characters / missing sessionToken. Always catch and degrade to
plain manual entry (hide the dropdown, let the shopper keep typing in the
now-plain line1 field). Never show a blocking error or disable the form.
This mirrors how the backend itself treats a Places API failure: it disables
suggestions, it never fails the checkout.
400 "property X should not exist", an unknown field in the body
Every write endpoint validates against a strict allow-list. One property that isn't in the documented payload rejects the whole request:
{
"statusCode": 400,
"code": "BAD_REQUEST",
"error": "Bad Request",
"message": "property lat should not exist, property lng should not exist"
}This does not degrade. The call simply never succeeds, so a field sent on
every submit blocks that step for every shopper. The message names each
offending property; remove them from the body rather than retrying.
The one that bites in practice is the address step, because
getAddressDetails() resolves an address carrying lat, lng and
formattedAddress, none of which any address endpoint accepts, so spreading
it into setShippingAddress() / setBillingAddress() sends all three. The
SDK (≥ 1.53.0) strips exactly those three for you, so the spread is safe
there; over raw HTTP it is not, and you must build the body from the documented
fields. Coordinates are never accepted from the client by design: zone matching
decides which shipping rate is offered and charged, so the server resolves them
itself from placeId. Use address.lat/address.lng for your own UI (a map
pin, a distance readout) and pass placeId.
Edge cases
Checkout expiration
- Checkout expires after 30 minutes from creation
- The
expiresAtfield in the checkout response tells you the exact expiry time - Every checkout operation checks expiration. If expired, it returns HTTP 400 with
message: "Checkout session has expired"and the genericcode: "BAD_REQUEST". It is not a 410, and there is noCHECKOUT_EXPIREDcode, so branch on the message rather than on a code that never arrives. - The expiry check only fires while the checkout is still
PENDING. A checkout that already reached a terminal state does not start returning "expired" once the clock runs out. You get whatever that state's error is instead. - What to do when expired: Create a new checkout from the same cart. The cart is still valid.
- Optional: Show a countdown timer based on
expiresAtso the user knows how much time they have
Rate limiting
Two tiers, both keyed per IP address, not per sales channel and not per API key. Both are evaluated on every request and the most restrictive verdict wins:
| Tier | Default limit | Window |
|---|---|---|
| Short | 60 requests | 1 minute |
| Long | 1000 requests | 1 hour |
Because the key is the client IP, everyone behind one NAT or one corporate proxy shares a budget, and a server-side integration that funnels all traffic through one host burns one budget for every shopper. That is the reason to call the storefront API from the browser rather than proxying it.
Sensitive endpoints are much tighter than 60/min. These are the storefront ones that bite:
| Endpoint | Limit |
|---|---|
POST /customers/register | 3 / minute |
POST /marketing/subscribe | 3 / minute |
GET /newsletter-benefit | 30 / minute |
POST /customers/forgot-password | 3 / minute |
POST /products/{id}/reviews | 3 / minute |
POST /products/{id}/review-photo | 10 / minute |
POST /customers/login | 5 / minute |
POST /customers/reset-password | 5 / minute |
POST /orders/lookup | 5 / minute |
POST /stock-alerts | 5 / minute |
POST /checkout/{id}/gift-card | 5 / minute |
POST /gift-cards/balance | 5 / minute |
POST /cart/{id}/coupon | 10 / minute |
POST /customization-upload | 10 / minute |
GET /checkout/address-details | 30 / minute |
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.
On 429 the body is:
{
"statusCode": 429,
"code": "RATE_LIMITED",
"message": "Rate limit exceeded — retry in 42 seconds.",
"details": { "retryAfterSeconds": 42, "limit": 60, "ttlMs": 60000 }
}Switch on code === 'RATE_LIMITED' (that one is a real code) and wait details.retryAfterSeconds (also sent as the Retry-After header, in seconds) before retrying. Show "Please wait a moment…" rather than a raw error. From the SDK that reads err.details.code and err.details.details.retryAfterSeconds. Yes, details twice, and that is not a typo: BrainerceError.details is the whole response body, and this body has its own details block. That double-hop is the norm, not an exception, because details is the envelope slot every error uses for structured context.
Headers on every response, success or failure. The short tier is unsuffixed, the long tier carries a -long suffix:
X-RateLimit-Limit/X-RateLimit-Limit-long: the tier's ceilingX-RateLimit-Remaining/X-RateLimit-Remaining-long: requests left in that windowX-RateLimit-Reset/X-RateLimit-Reset-long: seconds until that window resets
The SDK already retries once automatically on a 429 that carries Retry-After, waiting the header's value capped at 60 seconds. A second 429 surfaces to you as a BrainerceError, so handle it and don't add your own retry loop on top.
Domain / Origin restrictions
A sales channel has one domain, set in the dashboard under Channels → your channel →
Settings. What it enforces depends on the channel's mode.
LIVE channels
- The request must carry an
Originheader, and it is not treated as "no origin to check". Mismatch is rejected with403 Origin not allowed; a missing header is rejected earlier, by the guard described above, with403 Origin header required. Server-side rendering and Route Handlers send noOriginby default, which is why they fail here — but the fix is to set one, not to reach for an admin key. See Calling from a server below. - The channel must have a
domain. A LIVE channel without one rejects every request with403 Connection missing domain. - The
Originhost must equal thedomain's host, or be a subdomain of it:shop.example.commatchesexample.com. Comparison is case-insensitive, a leadingwww.is ignored on both sides, and the port is significant, sohttp://localhost:5173does not matchhttp://localhost:3000. The scheme is not compared. - Error on mismatch:
403 Origin not allowed.
TEST channels
- With no
domainset, anyOriginis accepted. This is deliberate: during early development the storefront host is unpredictable (localhost, a tunnel, a preview URL). It is also the reason a TEST channel is not a security boundary. Lock it down by setting adomain, or switch to LIVE. - With a
domainset, a request that carries anOriginis matched exactly as LIVE does, so a TEST channel pointed at a real domain cannot be reached from arbitrary origins.
⛔ "Any origin" never means "no origin". The matching rules above are the second of two
layers. Every /api/vc/* route first passes BrowserOriginGuard, which requires the header
to be present at all — in TEST as much as in LIVE, with or without a domain — and answers
403 Origin header required when it is missing, before any channel is even looked up. So a
curl or a server-side fetch that sends no Origin is refused on a wide-open TEST
channel exactly as it is on a locked-down LIVE one. See Calling from a server below for
the supported way to do that.
- TEST mode is not read-only. It is the same API surface as LIVE, against the same store data, governed by the same per-channel scopes.
Calling from a server
Server-side rendering, Route Handlers and background jobs are supported. Pass origin
when you construct the client and the SDK sends it on every request:
const client = new BrainerceClient({
salesChannelId: 'vc_abc123...',
origin: 'https://mysite.com', // must match the channel's `domain` on LIVE
});⛔ Do not reach for an admin brainerce_* key to work around this. An admin key is
full-access and store-wide; putting one in a storefront server to render a product page
trades a scoped, per-channel credential for one that can rewrite the whole store, and it
only takes one logged request or one leaked env var. The origin option is the supported
answer and costs one line.
⛔ Do not hardcode the value. On LIVE it must match the channel's domain, so a
build-time constant sends the wrong host from any deploy whose URL you did not know when
you wrote it — the common case for AI-builder and preview deploys. Derive it from the
incoming request, falling back to a configured site URL. The npx create-brainerce-store
starter does exactly this in src/core/lib/brainerce.server.ts.
During development. Use a TEST channel with no domain. Do not put localhost in the
domain field of a LIVE channel. domain is what LIVE identifies your storefront by, and
pointing it at loopback breaks production traffic.
Disabled channels and suspended stores are refused before any origin check runs:
403 Connection is disabled and 403 Store is suspended (code STORE_SUSPENDED)
respectively, on reads as well as writes.
Session and multi-tab behavior
| Scenario | Behavior |
|---|---|
| Same browser, multiple tabs | Tabs share the same cart (same sessionToken in localStorage) |
| Different browsers or devices (guest) | Each has its own cart (different sessionToken) |
| Logged-in customer, any device | All share the same cart (linked to customerId) |
| Guest logs in | Must explicitly merge carts (Task 8, step 8.3). Not automatic. |
| Two tabs complete same checkout | First succeeds, second gets same order (idempotent). No duplicate orders. |
| Two tabs add same item | Both succeed, quantity increases |
Idempotency
These operations are safe to retry:
| Operation | Retry behavior |
|---|---|
completeCheckout | Returns same order on second call (no duplicate) |
addToCart (same item) | Adds quantity to existing (does NOT create duplicate line item) |
applyCoupon (same code) | Returns success if already applied |
| Payment webhook + API complete | Both return same order |
Cart limits
A shopper-facing cart holds at most 50 distinct line items. The 51st addToCart for a
new product fails with 400 and the error code CART_LINE_LIMIT_REACHED:
{
"statusCode": 400,
"code": "CART_LINE_LIMIT_REACHED",
"message": "A cart can hold at most 50 different items. Remove something before adding more.",
"details": { "maxLines": 50 }
}It is a fixed platform limit. There is no per-store setting and it cannot be raised.
Detect it by the code, not by the sentence. With the SDK, the parsed body is on
BrainerceError.details, so the code is one level in:
import { BrainerceError } from '@brainerce/sdk';
try {
await client.addToCart({ productId, quantity: 1 });
} catch (err) {
const body = err instanceof BrainerceError ? (err.details as { code?: string; details?: { maxLines?: number } }) : undefined;
if (body?.code === 'CART_LINE_LIMIT_REACHED') {
showCartFull(body.details?.maxLines ?? 50); // not a generic error toast — see below
}
}details.maxLines carries the limit, so nothing has to parse the number back out of the
message. On the raw HTTP API code and details are siblings at the top level of the body,
as in the response above; only the SDK nests them under err.details.
⛔ If you already match the message text, keep that match as a fallback. The wording is unchanged, deliberately: storefronts built before the code existed still rely on it, and a storefront can be talking to a backend that has not been redeployed yet. Branch on the code first and fall through to the prose match, rather than replacing one with the other.
Which modes it applies to — this is the part that bites, because it is not uniform:
| Mode | Capped? |
|---|---|
storeId (public storefront) | Yes |
salesChannelId: 'vc_*' | Yes |
apiKey: 'brainerce_*' | No |
The admin key is uncapped on purpose: a B2B order or a bulk import legitimately carries
hundreds of SKUs, and that traffic is authenticated rather than anonymous. So a cart you
built over the /v1 API can exceed 50 lines and a storefront cart cannot, and the same
addToCart call can therefore succeed for one and fail for the other.
What the cap does not block. It is checked only when a new product line would be created. A cart already at or over the limit can still have quantities changed, items removed and coupons applied, so it never becomes unusable. Adding more of a product that is already in the cart is not a new line and is never refused by this rule.
Handle the 400 as a cart-full state, not as a failure. Show the shopper "your cart is full, remove something to add this", not a generic error toast, and keep the item they tried to add visible so one tap retries it.
⛔ Adding a bundle can fail halfway. The bundle and order-bump routes add their products one call at a time and are not wrapped in a transaction, so a bundle whose lines would cross 50 throws part-way and leaves the earlier products of that bundle in the cart. Re-read the cart after a failed bundle add and reconcile against what you asked for, rather than assuming the cart is unchanged.
Currency
- A store has one base currency (
store.currency). Every price on a product, cart or checkout is in that currency unless a field explicitly says otherwise. - All prices are strings with 2 decimal places (e.g.,
"150.50","0.00") - Always use
parseFloat(), neverparseInt() - Format using
Intl.NumberFormatwith the currency code that belongs to the amount you are formatting, never a hard-coded one
Regions can put a second currency on the page
"There is no currency conversion" used to be true and no longer is. Two distinct things ship, and confusing them is how a store ends up displaying one number and charging another:
Display-only conversion (product reads). Call getProducts({ regionId }) and each product/variant gains displayPrice, displaySalePrice and displayCurrency, the base price run through the daily FX snapshot. It is what you show. It is never what is charged. Use formatProductPrice() / formatVariantPrice() from the SDK; they prefer the display* fields when present and fall back to base.
FX-at-checkout (presentment). Pass regionId to createCheckout() and, if the region's currency differs from base and the region's payment provider can natively settle it and a charging rate is available, the checkout is pinned to that currency and the buyer is genuinely charged in it. The response then carries a presentment overlay:
const checkout = await client.getCheckout(id);
const shown = checkout.presentment
? formatPrice(checkout.presentment.total, { currency: checkout.presentment.currency })
: formatPrice(checkout.total, { currency: checkout.currency });presentment.total equals the amount charged; presentment.fxChargingRate and fxBufferPercent tell you the rate used. Any one of those three conditions failing makes the platform fall back to base currency rather than mis-charge, so presentment being absent is a normal outcome, not an error. Always branch on its presence; never assume the region currency was charged.
Do not mix currencies inside one number: never add a displayPrice to a base-currency amount, and never sum a presentment.* field with a checkout.* field.
Payment providers beyond Stripe
There is no
paymentUrlfield. Earlier revisions of this page described a hosted-page branch keyed onpaymentIntent.paymentUrl. No such field is ever returned, so the branch was dead. The URL a hosted-page provider wants you to open arrives inclientSdk.renderArg, falling back toclientSecret, and which surface to open it in is told byclientSdk.renderType.
The payment/intent response is:
{
"id": "pi_abc123",
"clientSecret": "https://secure.cardcom.solutions/...",
"amount": "499.80",
"currency": "ILS",
"status": "pending",
"provider": "cardcom",
"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 a real client secret
(pi_..._secret_...), for Grow an auth code, and for several providers a copy
of the same URL that is in renderArg (Morning, PayPal, Grow, Sola). That copy
is what makes reading clientSecret look correct until you meet a provider that
does not make it: MAX and Takbull return a real identifier there, as the contract
intends, so the allowed-host check rejects it and the shopper never reaches the
payment page. Cardcom and Stripe 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.
Branch on clientSdk.renderType, never on the provider name, and never on
"clientSdk exists". CardCom, Grow and even the sandbox provider all return a
clientSdk object, so "has clientSdk → Stripe Elements" is true for none of them.
clientSdk.renderType | What you read | How to handle |
|---|---|---|
'sandbox' | nothing | No payment UI. Call completeCheckout directly. |
'sdk-widget' | renderArg || clientSecret as the render argument, plus initConfig | Load clientSdk.scriptUrl, init with clientSdk.initConfig, mount into clientSdk.containerId. Stripe, PayPal, Grow. |
'iframe' | renderArg || clientSecret as the URL | Load it in an iframe. Path contains /embed/ → render inline; anything else → render in a modal overlay. On brainerce:payment-complete, call confirmSdkPayment(checkoutId, e.data.data) before navigating. This is required for confirm-time-charge providers (Sola), not optional. |
'redirect' | renderArg || clientSecret as the URL | Navigate the top-level window to it. On return, POST /payment/sdk-confirm once, then poll payment-status. |
Full code for each branch, including the postMessage contract for the inline
embed and the isAllowedPaymentUrl() allowlist you must run before any
provider-requested navigation, is in Core, Step 5.3. 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 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', such as PayPal, Morning, Takbull, iCredit):
import { safePaymentRedirect } from 'brainerce';
if (paymentIntent.clientSdk?.renderType === 'redirect') {
localStorage.setItem('brainerce_checkout_id', checkoutId);
// renderArg is the URL; clientSecret is only the fallback.
safePaymentRedirect(paymentIntent.clientSdk.renderArg || paymentIntent.clientSecret);
}After the shopper returns to your site, call client.confirmSdkPayment(checkoutId)
once. That call triggers the server-side verify-and-capture, is idempotent, and is safe
to skip on failure. Then poll client.getPaymentStatus(checkoutId) exactly like the
Stripe redirect flow (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.
}
confirmSdkPayment()andgetPaymentStatus()are sales-channel mode only. Both throwBrainerceError400 ("… is only available in vibe-coded mode") on a client built withstoreIdorapiKey. Build the payment return page against asalesChannelIdclient.
Required pages
Your website MUST have these pages/routes for the integration to work:
| Page | Purpose | When needed |
|---|---|---|
| Product listing | Display products from Brainerce | Always |
| Product detail | Single product page with variant selection | Always |
| Cart | View and manage cart items | Always |
| Checkout | Multi-step checkout form | Always |
| Order confirmation / return URL | Show success after payment, handle redirect from payment providers | Always |
| Order lookup | Let guests check order status by email + order number | Recommended |
| Login / Register | Customer authentication | If implementing Task 8 |
| Password reset | Reset password form (receives ?token= from email) | If implementing Task 8 |
| Auth callback | Handle the OAuth return. Receives ?oauth_success=&auth_code= on success and ?oauth_error=&error_description= on failure, never code/state, which the platform's own callback consumes | If implementing Task 10 |
The order confirmation page is critical. It must:
- Read
checkoutIdfrom localStorage - Poll payment status until confirmed
- Call
completeCheckoutif order not yet created - Clear all localStorage keys
- Show order number
Price & currency formatting
function formatPrice(priceString, currencyCode) {
const amount = parseFloat(priceString);
if (isNaN(amount)) return priceString;
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currencyCode,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
}
// formatPrice("150.50", "ILS") → "₪150.50"
// formatPrice("29.99", "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
→ Create payment intent (must come AFTER any gift card; apply/remove 400s once
the checkout is PAYMENT_PENDING — CHECKOUT_LOCKED)
→ clientSdk.renderType?
├── "sandbox" → completeCheckout directly
├── "sdk-widget" → Load clientSdk.scriptUrl → mount widget → Pay
│ → Poll status → completeCheckout
├── "iframe" → Load renderArg||clientSecret in an iframe (inline if the path
│ has /embed/, else in a modal) → Pay → on brainerce:payment-complete,
│ confirmSdkPayment(checkoutId, e.data.data) FIRST (required for
│ confirm-time-charge providers like Sola — see below) → Poll
│ status → completeCheckout
└── "redirect" → Navigate to renderArg||clientSecret → Return → confirmSdkPayment
→ Poll status → completeCheckout
→ Clear localStorage → Show confirmationGuest vs. logged-in
Do we hold a customer token in memory (or an HttpOnly session cookie)?
├── YES (logged in)
│ ├── Pre-fill checkout from /customers/me/checkout-prefill
│ ├── Show profile, orders, addresses links
│ └── Cart auto-linked to customer
└── NO (guest)
├── Show login/register links
├── Cart uses sessionToken
├── After login → merge cart (Task 8, step 8.3)
└── Order lookup via email + orderNumberPayment provider decision
Switch on paymentIntent.clientSdk?.renderType. Do NOT test whether clientSdk
exists, because every provider returns one, including sandbox. Do NOT switch on
the preferredRenderType you passed: the platform falls back to the provider
default when the provider does not declare your preference.
switch (paymentIntent.clientSdk?.renderType)
├── "sandbox" → completeCheckout directly (no payment UI)
├── "sdk-widget" → Load clientSdk.scriptUrl, call
│ globalName[initMethod](initConfig), render into
│ clientSdk.containerId. Stripe, PayPal, Grow.
├── "iframe" → clientSdk.renderArg || clientSecret is the URL.
│ Path contains "/embed/"?
│ ├── YES → render INLINE + listen for brainerce:resize /
│ │ brainerce:redirect / brainerce:payment-complete
│ └── NO → render inside a modal overlay
│ On brainerce:payment-complete: call confirmSdkPayment(checkoutId,
│ e.data.data) BEFORE navigating to the confirmation page — required
│ for every iframe provider, not just redirect ones. Some providers
│ (Sola) only tokenize the card inside the iframe; the real charge
│ runs at confirm time using that payload. Skipping this call leaves
│ the checkout stuck in PAYMENT_PENDING with no way to recover — those
│ providers are intentionally excluded from server-side payment-status
│ polling/reconciliation (retrying their confirm without the original
│ tokens would double-charge or fail).
├── "redirect" → clientSdk.renderArg || clientSecret is the URL. Save
│ checkoutId, navigate the top-level window to it. On
│ return: confirmSdkPayment(), then poll payment status.
└── undefined/other → Treat as unsupported: surface an error, do NOT guess.
Never fall through to Stripe Elements.Only provider === 'stripe' reads clientSdk.initConfig.publishableKey. Every
other sdk-widget provider has its own initConfig shape, so read it through
scriptUrl / globalName / initMethod, not by assuming a Stripe key.
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. Create the intent with the
tapped provider's id.
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. Not saving checkoutId before Stripe redirect
Wrong: Stripe redirects → confirmation page doesn't know checkoutId.
Right: localStorage.setItem('brainerce_checkout_id', checkoutId) before stripe.confirmPayment().
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. Right: Catch 404 → create new cart → save new sessionToken.
8. Hardcoding currency
Wrong: <span>₪{price}</span>
Right: <span>{formatPrice(price, storeConfig.currency)}</span>
9. Not checking inventory
Wrong: Always show "Add to Cart".
Right: Check inventory.quantity - inventory.reserved > 0. For VARIABLE products, check the selected variant's inventory.
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. Sending a product id as the marketing-tag itemId
Wrong: trackMarketingEvent('purchase', { items: [{ itemId: item.productId }] })
Right: itemId: item.sku
The SKU is what Brainerce publishes to the Google Merchant Center and Meta catalog feeds, so it is the only id the ad platforms can match a pixel event against. Any other id fails silently: the events arrive, the platform reports an id its catalog has never seen, attribution and dynamic remarketing quietly stop working, and nothing errors anywhere.
12. Asking the merchant for a GA4 / pixel id
Wrong: an env var, a config file, or a setup prompt for G-… or a pixel id.
Right: client.initTracking((await client.getStoreInfo()).tracking).
The ids are resolved server-side from the marketplace apps the merchant already
connected. An empty tracking object means no tag app is connected, which is a
normal state and not a reason to prompt.
13. Marketing tags behind a CSP with no connect-src entries
Wrong: connect-src 'self' https://api.brainerce.com and a Meta pixel that
"loads fine."
Right: add the vendor beacon hosts. See the marketing-tags section of the
integration guide.
The tag scripts load and then every hit is blocked. In the ad dashboard this is
indistinguishable from having made no sales. Under script-src 'strict-dynamic'
do NOT add them to script-src; host allowlists are ignored there.
14. Firing purchase without transactionId
Wrong: trackMarketingEvent('purchase', { value, currency, items })
Right: include transactionId: order.id.
It becomes GA4's transaction_id, Meta's eventID and TikTok's event_id. A
shopper who refreshes the confirmation page otherwise counts as a second sale,
inflating reported revenue. Guard the call yourself too (a sessionStorage key
per order id).
15. Telling the visitor they're subscribed
Wrong: await marketing.subscribe(...) → "You're subscribed! 🎉"
Right: "Check your email to confirm."
subscribe() starts a confirmed opt-in. The address is not on the list and no
campaign can reach it until the recipient clicks the link in their inbox. The
success response is { ok: true } for every case, including an address that is
already subscribed and one suppressed after a hard bounce, so there is nothing
in it to branch on. Most people never click; a storefront that claims otherwise
is why the merchant's list looks bigger than their sends.
The same rule covers the welcome offer. If the merchant configured one,
marketing.getBenefit(locale) gives you the terms to advertise beside the field
(SDK >= 2.7). It does not give you a code, and there is none to show: the coupon
is minted when the recipient clicks the confirmation link and is emailed to them
at that moment. That is deliberate, because a code rendered on a public form is
a code anyone can pass on. Do not build a "preparing your code" screen either;
the confirmation page is served by the API and already shows the code, its
expiry and its terms.
getBenefit() accepts no email address and there is no eligibility check to
call. A per-address answer would be an unauthenticated way to test who already
subscribed, which is the leak the uniform { ok: true } exists to prevent. The
offer is one per address per store, forever: say so in the terms rather than
trying to detect a repeat.
16. Wording a back-in-stock alert as a subscription
Wrong: await stockAlerts.subscribe(...) → "Subscribed! 🎉"
Right: "We'll email you when it's back."
It is one message about one item, not a mailing list. Nobody is subscribed to anything, no customer account is created, and the person hears nothing else as a result. Someone who reads "subscribed" and gets no newsletter has been misled; someone who never wanted one has a complaint. For the same reason, do not hide the button from a shopper who has unsubscribed from marketing, because they can still legitimately ask about a product.
And do not promise timing. The alert waits for stock to hold, then goes out in waves sized to the units that arrived, so a shopper can sit through a restock without hearing. "We'll email you when it's back" is true; "you'll be the first to know" is not.
17. Putting the button on an item that cannot use it
Wrong: rendering "notify me" wherever inStock === false.
Right:
store.stockAlertsEnabled !== false &&
inv?.trackingMode === 'TRACKED' &&
!inv.canPurchase &&
(inv.backorderMode ?? 'NONE') === 'NONE';Requests for anything else are silently ignored. The response is uniform on
purpose, so it cannot be used to read a store's stock levels, which means a
button in the wrong place looks like it worked and does nothing. The merchant
switch is the one most integrations forget: it lives in channel settings, it can
be turned off at any time, and the only way your storefront learns about it is
stockAlertsEnabled on getStoreInfo().
Same for a variable product with no variantId: the alert then waits on the
product as a whole, and a shopper who wanted the medium is mailed when the small
returns.
18. Showing a gift card as a discount
Wrong: adding amountApplied to discountAmount, or subtracting it from
total, so the summary reads "Total ₪150.50".
Right: leave total exactly as the API sent it and add two lines under it:
Total ₪205.00 ← unchanged; this is the amount tax was calculated on
Gift card −₪54.50 ← checkout.tenders[].amountApplied, its own line
Amount due ₪150.50 ← checkout.providerAmountDueA gift card is a means of payment, not a price reduction. The order is still
worth what it is worth, and tax is still charged on that, which is why the
platform returns providerAmountDue as its own field instead of moving the
total. A storefront that folds it into the discount shows the shopper — and
prints on their receipt — a taxable base that is smaller than the one they were
actually charged tax on.
Two follow-ons from the same mistake: read applied cards from
checkout.tenders, not from the applyGiftCard response you kept in state (the
hold lives on the server, so your copy vanishes on reload while the card is
still applied), and remove by tenderId, never by code, because a checkout can
carry several cards.
19. Explaining why a gift card was refused
Wrong: if (err.message.includes('expired')) show('This card has expired').
Right: one message for every refusal — "We can't use this code" — and let
the shopper try again.
Unknown, expired, spent, disabled and wrong-currency all come back as the same
400 with the same sentence, deliberately, because anything else is an oracle for
walking the code space. checkGiftCardBalance is the same: an unknown, disabled
or expired code all answer { balance: "0.00", usable: false }. There is
nothing to branch on, so a branch you write there is dead code that will one day
tell a shopper the wrong thing.
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
- 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
Checkout
- Checkout creates from cart
- Customer email required
- Shipping flow works (if hasShipping)
- Pickup flow works (if applicable)
- Shipping method selection works
- Order summary correct (uses API total)
- Gift-card field rendered when
features.hasGiftCards(and nothing when it is false) - Gift card shown as its own line below the total, plus an "Amount due" line — never inside the discount block, and
totalnever modified - Applied cards read from
checkout.tenderson every render, so they survive a page reload - Remove uses
tenderId, never the code - One refusal message for every rejected code, with no attempt to say why
- Gift-card field hidden once the payment intent exists
Payment
- Payment providers load
- Payment intent creates
-
providerAmountDue === "0.00"(gift cards cover the order) skips the payment step, callscompleteCheckoutdirectly, and still clears the cart afterwards - The charged amount is the intent's own
amount— gift cards are never subtracted client-side - Stripe Elements mount (if Stripe)
- Hosted payment redirect works (if CardCom/Grow)
- Sandbox completes directly
- CheckoutId saved before redirect
- Confirmation page polls status
- Order created after payment verified
- Order number displayed
Post-Purchase
- localStorage cleared
- Confirmation email mentioned
- Guest order lookup works
- Order status displays correctly
- Tracking link shows when available
Errors
- Product 404 handled
- Out-of-stock handled
- Payment failures: user-friendly message
-
503 PAYMENTS_PAUSEDfromcreatePaymentIntent: message shown, cart kept, no retry loop - 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 is public. - Purchaser-only. The authenticated customer must have an eligible order containing the product. Otherwise
submitProductReviewreturns403 Forbiddenwithreason: 'no_eligible_order'.- Physical products → order status
SHIPPED/COMPLETED/DELIVERED. - Downloadable products → additionally
PAID/PROCESSING.
- Physical products → order status
- One review per customer per product. Duplicate submits return
409 Conflict. UseupdateMyProductReviewto edit,deleteMyProductReviewto start over. - No author name/email in the body. Both are derived server-side from the customer profile. Pass only
{ rating, body? }. ratingmust be an integer 1-5 (DB CHECK constraint rejects anything else).bodyis optional, max 5000 chars. It is plaintext, so always pipe it through your sanitizer before rendering.- Submit endpoint is rate-limited to 3 / 60s / IP; edit/delete to 5 / 60s / IP. Show the user "please wait a moment" on
429, do not retry automatically. verifiedPurchaseis alwaystruefor customer-submitted reviews (since they must purchase). External-api imports (Yotpo/Judge.me) create reviews withverifiedPurchase: falseand supply author info directly.hiddenAtis set only by merchants via the admin surface. Storefront list endpoint never returns hidden reviews.- Emit
aggregateRatingin Product JSON-LD only whenreviewCount > 0. Do not invent values. - Stores with
reviewsEnabled = falsereturn403on storefront review endpoints withreason: 'reviews_disabled'. - Use
client.getMyProductReview(productId)to decide whether to render a sign-in CTA, a not-eligible message, a submit form, or an edit form. Returns{ eligible, reason, myReview, photos, myImages }.
Review photos
- Keys, never URLs.
imageKeysaccepts thekeyfromuploadReviewPhoto(). A URL is rejected. Keys are resolved against the store's own assets, and a key that already belongs to a different review is refused, so a photo published on one review cannot be lifted onto another. - Upload needs the same rights as the review.
uploadReviewPhotorequires a customer JWT and an eligible order, checked before the bytes are stored. This is stricter thanuploadCustomizationFile, which is anonymous by design. imageKeysreplaces the set on update. Send the keys you want to keep. Omitting the field leaves existing photos untouched;[]removes them all.- Max 5 photos per review, 5MB and 40 megapixels each, JPEG/PNG/WebP/GIF. The declared MIME is cross-checked against the file's real bytes, so a renamed SVG or arbitrary binary is rejected.
- EXIF is stripped, so a customer's GPS coordinates never reach the storefront, while the orientation tag is applied first so phone photos are not sideways.
- Photos count against the merchant's plan storage. An upload never attached to a submitted review is reclaimed after 7 days.
- Approval is off by default, matching review text. Stores that turn it on hold each new photo until the merchant shows it; photos already approved stay published. Read
photos.requiresApprovaland tell the customer. - On an approval-required store the submit/update response's
imagesis empty, because it carries only shopper-visible photos. Re-fetchgetMyProductReview()and rendermyImagesso the customer sees their pending upload. listProductReviewsdefaults tosort: 'photos_first'. Passsort: 'newest'for chronological order.review.imagesis always an array. Renderimg.width/img.heighton the<img>to avoid layout shift.
Content (typed merchant content)
- Sanitize before rendering.
FAQ.items[i].answer,RICH_TEXT.html,PAGE.html, andProduct.descriptionare merchant-authored HTML. The server does NOT pre-sanitize FAQ/RICH_TEXT/PAGE. ALWAYS pipe throughisomorphic-dompurify(or equivalent) beforedangerouslySetInnerHTML. Skipping this is XSS.Product.descriptionmay also contain<video>and host-locked YouTube/Vimeo<iframe>embeds, so your sanitizer must allow those tags (iframe restricted towww.youtube.com/www.youtube-nocookie.com/player.vimeo.com) and your CSPframe-srcmust list those hosts. See Core Integration → Product descriptions can contain video and embeds. get()/getBySlug()returnnullon 404. Render a hard-coded fallback whennullso the page never crashes when the merchant hasn't seeded yet.list()returns[]on empty.'main'is the universal default key for every type.client.content.faq.get()(no args) resolves tokey='main'. Don't ship topical entries under'main'.keyis immutable after save. The dashboard disables the field on edit. Plan the slug carefully on first create:^[a-z][a-z0-9-]*$, max 64 chars.- Topical entries use kebab-case keys (
'shipping','returns','holiday-2026'). Don't mix'main'and topical rows for the same surface; pick one convention. - Public reads carry
Cache-Control: public, max-age=300, stale-while-revalidate=60. Merchant edits propagate within ~5 minutes. Don't add extra client caching. - Locale resolution is server-side. Pass
localetoget/list/getBySlug, and the server runs deep-merge with empty-string fallthrough and returns the resolveddata. Do NOT overlay translations client-side. - Locale fallback rules: missing locale / null / === default → returns base
dataunchanged.translations[locale]missing → returns base unchanged. Otherwise deep-merge (arrays merge by index). - Custom fields are merchant-defined.
row.customFieldsisRecord<string, string>. Read defensively (if (faq.customFields.helpEmail) { ... }) and never assume keys exist. - Channel scoping is enforced in vibe-coded mode only. Storefront mode (raw
storeId) bypasses thesalesChannelIdsfilter, and non-empty restrictions are only honored when the request arrives via/api/vc/:connectionId/.... announcement.list()returns ALL active announcements. Filter client-side bystartsAt/endsAtISO timestamps so the cached server response can be shared across visitors.- Persist
announcement.dismissibledismissals inlocalStoragekeyed by announcementid(not by text, which may be edited). The scaffold's<AnnouncementBar>does this for you. - Admin write operations require
apiKeymode.client.content.faq.create()/update/publish/removethrowBrainerceError(403)when called from storefront / vibe-coded mode. - Every admin content and blog call takes an explicit
storeIdas its last argument.content.<type>.create(input, storeId),content.update(id, input, storeId),content.publish(id, storeId),content.unpublish(id, storeId),content.remove(id, storeId),content.findById(id, storeId),content.listAdmin({ storeId, type?, status? }), and the matchingblog.*calls. Admin mode has no ambient store, since the constructor'sstoreIdonly takes effect in storefront mode, so the SDK sends it as a query param and never as a body field. A missingstoreId, or one naming a store your key is not bound to, is rejected by the store scope guard with403 STORE_SCOPE_REQUIREDbefore the handler runs, so do not write a handler for a 400. Scopes:content:read/content:write, andblog:read/blog:write. - The public reads throw in admin mode.
content.<type>.get(),content.<type>.list(),content.page.getBySlug()andblog.getPost(slug)are storefront APIs. From anapiKeyclient they throw rather than issue a request that could only fail, because the admin API has no by-key or by-slug read. Usecontent.listAdmin({ storeId, type })/content.findById(id, storeId), orblog.getPosts({}, storeId)/blog.findById(id, storeId). - The two admin
findByIdmethods disagree on 404.blog.findById(id, storeId)resolves tonull;content.findById(id, storeId)throws. Check which one you are calling before writing the miss path. - Always create in DRAFT. Publishing is a separate explicit step, so never call
publish()immediately aftercreate()without user confirmation. The dashboard enforces this; respect the same boundary in admin scripts. update()replacesdatawholesale. There is no partial-data patch. Read the row first (findById(id, storeId)/listAdmin({ storeId })), spread it, then send the merged result.remove()is irreversible. Storefronts depending on the row fall back to defaults or 404. Preferunpublish()for soft-removal.- Use
client.getStoreDirection(locale)for<html dir>. Returns'ltr' | 'rtl'for any BCP-47 tag (Arabic, Hebrew, Persian, Urdu, Yiddish today, plus any future RTL locales). Do NOT maintain a local RTL set.
SEO & Discoverability
- Use SDK JSON-LD builders, never hand-rolled objects.
buildProductJsonLd/buildArticleJsonLd/buildCollectionPageJsonLd/buildOrganizationJsonLd/buildWebsiteJsonLd/buildBreadcrumbJsonLd+jsonLdScriptPropsencode Google's structured-data rules (aggregateRating gating, AggregateOffer for VARIABLE products, XSS-safe serialization). Hand-rolled JSON-LD drifts from policy. buildProductJsonLd's Offer always includesitemCondition(hardcodedNewCondition),priceValidUntilwhen the product has an active sale-price window (salePriceEndsAt), andshippingDetailswhen you passshipping: storeInfo.shipping(real flat-rate/free zones, never fabricated, omitted entirely if you don't pass it).- Emit brand/Authority signals on the homepage.
buildOrganizationJsonLd(brand entity → Knowledge Panel + AI answers viasameAssocial profiles) +buildWebsiteJsonLdwithsearchUrlTemplatepointing at your real search route (sitelinks search box). These are the free off-page authority signals, and no paid backlink provider is needed. - Product JSON-LD on single-product pages ONLY. Never on category/listing pages, because Google's Product rich results reject listing-page markup. On a category page emit
buildCollectionPageJsonLd+buildBreadcrumbJsonLdinstead. - Category pages are the highest-leverage organic surface, so build them AND link to them.
/category/[slug]viaclient.getCategoryBySlug(slug)for the metadata +getProducts({ categories: [id] })for the grid.getCategoryBySlugreturnsPromise<CategoryDetail>and throws on a miss, it does not resolve tonull— 404 it withconst category = await client.getCategoryBySlug(slug).catch(() => null); if (!category) notFound();. That's the opposite ofclient.content.page.getBySlug/client.blog.getPost, which returnPromise<X | null>and need no.catchat all — the return type in the SDK is the discriminator, not the domain. Both of those are storefront-mode reads and throw from anapiKeyclient; the admin equivalents split the same way, withblog.findById(id, storeId)resolving tonullandcontent.findById(id, storeId)throwing. RendermetaDescriptioninto the meta tag and the sanitizeddescriptionHTML below the grid. A category page with no real<a href="/category/{slug}">pointing at it from anywhere in the storefront (nav, category tiles, breadcrumbs) is effectively invisible to crawlers even if it's in the sitemap. Sitemap inclusion is not a substitute for on-page internal links. Renderproduct.categories[].slugas a real link on the product page too (e.g. in a breadcrumb), not just a filter chip. - A category needs to be published to the sales channel to appear at all.
getCategories()/getCategoryBySlug()only return categories explicitly published to the callingconnectionId, so a category can exist with a full AI-written description and still resolve to an empty tree / 404 on a specific storefront if it was never published to that channel. Publish via the dashboard's category grid (orPOST /categories/:id/publish-sales-channel) before expecting it to render. - Product + category + blog entries MUST be in
sitemap.xml, and products MUST usegetProductSitemapEntries. The public listing API clampslimitto 100, so a naivegetProducts({ limit: 1000 })sitemap silently truncates at 100 products;getProductSitemapEntries(client, { siteUrl, locales, defaultLocale })uses a dedicated lightweight endpoint (up to 5000 products in one call) and falls back to pagination on older backends. AppendgetCategorySitemapEntries(...)andgetBlogSitemapEntries(...)too. The SEO Autopilot writes category descriptions + publishes articles automatically, and a missing sitemap entry means they never get crawled. - robots.txt must ALLOW the AI search crawlers by name:
OAI-SearchBot,ChatGPT-User,Claude-SearchBot,Claude-User,PerplexityBot,Perplexity-User,Bingbot,Applebot,Amazonbot. These agents power ChatGPT/Claude/Perplexity/Copilot shopping answers, read raw HTML only, and respect robots.txt; blocking them (or fronting the store with an unconfigured bot blocker) makes the store invisible to AI assistants. Keep/api/,/auth/,/checkout/,/account/disallowed for everyone. Training bots (GPTBot,ClaudeBot,CCBot,Google-Extended,Meta-ExternalAgent) are allowed by default in the scaffold, and blocking them does not affect search visibility, so it's a merchant policy choice, not an SEO one. - Serve the IndexNow key file exactly as documented:
GET /indexnow-key.txtreturninggetStoreInfo().seo.indexNowKeyastext/plain, 404 whilenull. The platform pings IndexNow on every blog publish; the ping is silently skipped until this file is live. The key is not a secret. - Serve
/llms.txtAND/agents.md. llms.txt is the AI-answer-engine summary (store + categories + recent articles); agents.md is the agent-facing guide (machine surfaces, key URLs, how buying works). See the scaffold'sapp/llms.txt/route.tsandapp/agents.md/route.ts. Multi-locale stores: these dotted routes (plusindexnow-key.txt) must live at the app ROOT, never inside[locale]/. The locale middleware matcher skips dotted paths, so a locale-nested copy resolves as the homepage withlocale="llms.txt"and serves HTML. List the public Storefront MCP endpoint (POST /api/mcp/storefront/{salesChannelId}) inagents.mdtoo, so agents that read it know to call structured tools instead of crawling; see Storefront MCP for the endpoint and tool reference. - Render the Google site-verification meta tag when configured.
getStoreInfo().seo.googleSiteVerification(merchant-set in channel settings) →<meta name="google-site-verification" content={token} />in the root layout<head>. It's what lets the merchant verify the domain in Search Console and claim the website in Merchant Center. - Resolve renamed slugs before 404ing. The platform records every product/blog slug rename. In the not-found path of
/products/[slug]and/blog/[slug], callclient.resolveSlugRedirect('product' | 'blog', slug). On a hit,permanentRedirect()to the returnedcurrentSlug(default locale unprefixed); only anullresult falls through tonotFound(). Without this, every slug edit in the dashboard permanently 404s the old URL and its accumulated ranking. - Blog pages are required when the store publishes posts.
/blog(list viaclient.blog.getPosts()) and/blog/[slug](viaclient.blog.getPost(slug),null→ 404, sanitizepost.contentbefore rendering). Subscribe to theblog.post.published/blog.post.updatedwebhook events to revalidate ISR caches.