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 is required for variable products"
productIdMust exist and be published to this connectionHTTP 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. 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.
  • A kit has no inventory block. Read kitAvailable instead: null means unlimited, 0 means not sellable. Its stock is whichever component runs out first, so it can drop without the kit itself changing.
  • kitComponents is 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 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 (multipart file field)
Max size5 MB
MIME allowedimage/* only (jpeg/png/webp/gif). Others → HTTP 400
Throttle10 uploads / minute per IP → HTTP 429 Too Many Requests
RetentionAt least 7 days. Deleted after 7 days if the cart isn't ordered.
Response{ url: string, key: string, size: number, mimeType: string }

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

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), 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.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

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:

messageWhat happenedWhat to show
Invalid coupon codeNo coupon with that code on this store"That code isn't valid"
Coupon is not available for this storeCoupon exists but is not published to this channel"That code isn't valid". Do not reveal that it exists
Coupon is not yet activestartsAt is in the future"This coupon isn't active yet"
Coupon has expiredendsAt has passed"This coupon has expired"
Coupon usage limit reachedStore-wide usageLimit exhausted"This coupon is no longer available"
Minimum order amount of {amount} requiredCart subtotal below the coupon minimumShow the required amount, keep the code in the input
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

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. 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 minShow "Pick at least N"
MAX_SELECTIONS_EXCEEDEDMore picks than maxShow "Pick at most N", and disable extra checkboxes client-side to skip the round-trip
SINGLE_GROUP_MULTIPLE_PICKSMore than one modifier sent for a SINGLE groupBug in your renderer. Re-render as radio
UNKNOWN_MODIFIERA modifierId does not belong to the named groupRefetch the product and re-render; the catalog moved under you
UNKNOWN_GROUPA modifierGroupId is not attached to this product / variantSame again: refetch the product
MODIFIER_DISABLED_FOR_VARIANTThe picked modifier is disabled for the active variant (per ModifierVariantDisable)Refetch the product for this variant; the option should be absent
MODIFIER_NOT_AVAILABLEModifier was sold-out at submit time (available: false)Refetch the product, ask the customer to re-pick
NESTED_DEPTH_EXCEEDEDMore than 3 levels deep in nestedByModifierIdBug in your renderer. Never go past 3 levels
NESTED_REQUIRES_PRODUCT_REFYou sent nestedByModifierId[parentId] but the parent modifier has no referencedProductIdDrop the nested entry; that parent isn't a combo
INVALID_PRICE_DELTAServer-side decimal parse failedA bug. This should not happen for SDK callers, since it's server-validated on save
MODIFIER_PRICE_FLOOR_VIOLATEDThe unit price after applying downsells would go below 0Show "Cannot apply more discounts on this item" and let the customer remove a downsell

Generic 400, no internals leak. MODIFIER_PRICE_FLOOR_VIOLATED 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

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. These are the storefront ones that bite:

EndpointLimit
POST /customers/register3 / minute
POST /marketing/subscribe3 / minute
GET /newsletter-benefit30 / minute
POST /customers/forgot-password3 / minute
POST /products/{id}/reviews3 / minute
POST /products/{id}/review-photo10 / minute
POST /customers/login5 / minute
POST /customers/reset-password5 / minute
POST /orders/lookup5 / minute
POST /stock-alerts5 / minute
POST /checkout/{id}/gift-card5 / minute
POST /gift-cards/balance5 / minute
POST /cart/{id}/coupon10 / minute
POST /customization-upload10 / minute
GET /checkout/address-details30 / 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 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 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.
  • 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.

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/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:

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

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://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.renderTypeWhat you readHow to handle
'sandbox'nothingNo payment UI. Call completeCheckout directly.
'sdk-widget'renderArg || clientSecret as the render argument, plus initConfigLoad clientSdk.scriptUrl, init with clientSdk.initConfig, mount into clientSdk.containerId. Stripe, PayPal, Grow.
'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, 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() and getPaymentStatus() are sales-channel mode only. Both throw BrainerceError 400 ("… is only available in vibe-coded 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 providersAlways
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
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 checkoutId from localStorage
  2. Poll payment status until confirmed
  3. Call completeCheckout if order not yet created
  4. Clear all localStorage keys
  5. 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 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)
    └── Order lookup via email + orderNumber

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


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 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
  • Payment intent creates
  • 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
  • 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_PAUSED from createPaymentIntent: 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 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. Do not invent values.
  • Stores with reviewsEnabled = false return 403 on storefront review endpoints with reason: '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. 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 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 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 vibe-coded 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 / vibe-coded 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.
  • 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.
  • 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.

On this page

Validation rulesCustom-field filters (getProducts({ metafields }))CartKits (type: 'KIT')Customization fields (buyer input on product page)DATE/DATETIME value formatCustomization uploadsCheckoutGift cardsCustomerPassword strength (registration AND reset)Error codesThe envelope has a fixed key set, so structured context rides in detailsCart errorsCoupon rejections have NO specific code, so match on messageModifier validation errorsCheckout errorsThe rest of the checkout failures are plain 400s and 404s, so match on messagePayments are paused during a store ownership transfer (503 PAYMENTS_PAUSED)Gift card refusals are all the same answer, on purposeAddress autocomplete failures (non-fatal, never block checkout on these)400 "property X should not exist", an unknown field in the bodyEdge casesCheckout expirationRate limitingDomain / Origin restrictionsSession and multi-tab behaviorIdempotencyCart limitsCurrencyRegions can put a second currency on the pagePayment providers beyond StripeRequired pagesPrice & currency formattingConditional flow decision treesProduct type → Add to CartCheckout flowGuest vs. logged-inPayment provider decisionCommon AI mistakes to avoid1. Sending variantId: null for SIMPLE products2. Using parseInt() on prices3. Calculating totals manually4. Calling completeCheckout before payment5. Not saving checkoutId before Stripe redirect6. Creating new cart/checkout after payment failure7. Not handling cart 4048. Hardcoding currency9. Not checking inventory10. Forgetting Authorization header11. Sending a product id as the marketing-tag itemId12. Asking the merchant for a GA4 / pixel id13. Marketing tags behind a CSP with no connect-src entries14. Firing purchase without transactionId15. Telling the visitor they're subscribed16. Wording a back-in-stock alert as a subscription17. Putting the button on an item that cannot use it18. Showing a gift card as a discount19. Explaining why a gift card was refusedChecklistProducts & NavigationCartCheckoutPaymentPost-PurchaseErrorsCustomer (if implemented)Product ReviewsReview photosContent (typed merchant content)SEO & Discoverability