Changelog
Every change to the public API — additions, deprecations, removals. Newest first.
We publish a Changelog entry for every change to the /v1/* public API surface, plus material changes to authentication, rate limits, error codes, and webhook semantics. Internal dashboard routes are not tracked here — they're under /api/<module> and may change without notice (see Versioning).
Subscribe by watching this page (RSS feed coming with v2 docs).
2026-10-29 — Order status history: what the shopper-facing statusHistory carries
Additive, and one privacy tightening. A reader of statusHistory that already read from, to and timestamp keeps working.
statusHistoryentries now also carrystatus(the same asto) andat(the same astimestamp). The storefront starter's status timeline readsstatusandat, which the older entries did not have, so it rendered nothing. Entries written in the older{ status, at, note }shape are passed through unchanged.- Only statuses and time reach a shopper. The order endpoints a shopper reads (
GET /v1/customers/{id}/ordersand the storefront'sGET /vc/{connectionId}/customers/me/orders) returnfrom,to,timestamp,statusandat. The team member who made a change, the source of the change and the names of the store's custom statuses stay internal. - More changes are recorded. A status change made from the dashboard,
PATCH /v1/orders/{id}, the MCP tools or the assistant, and a fulfilment, now add an entry. Before, only refunds did. Orders created before 29 October 2026 keep what they had.
2026-10-28 — Order tags: internal labels on an order
Additive. A call that does not use the new field or parameters behaves exactly as before.
- New field
tags(list of text) on every order returned byGET /v1/ordersandGET /v1/orders/{id}. It is empty for an order nobody tagged. Tags are for the store team: the customer never sees them and nothing is sent when they change. - New route
PATCH /v1/orders/{id}/tags(SDK:setOrderTags, scopeorders:write, supportsIdempotency-Key). It replaces the whole list; an empty list clears it. Up to 100 tags of 100 characters, no commas. Repeats that differ only in letter case are merged, and a tag takes the spelling the store already uses. An imported (view-only) order can be tagged too. - New filters
tagandexcludeTagonGET /v1/orders(SDK:getOrders({ tag, excludeTag })). Comma separated;tagkeeps orders carrying at least one of the tags,excludeTaghides orders carrying any. The text must match exactly, letter case included. - Order tags are their own list. They are not the product tags or the customer tags, and renaming or deleting one of those never touches an order.
- Also: order webhook payloads carry
tags, and thesearchparameter of the product and customer lists (GET /v1/products,GET /v1/customers, and the dashboard search boxes) now also matches the names of the item's tags. The product search of a storefront instoreIdmode reads the same list, so a shopper typing a product tag name finds the products carrying it; storefronts connected as avc_*sales channel keep their own search, which does not look at tags.
2026-10-28 — Email settings: choose the part before the @ of the sending address
Additive on the email settings routes. A call that does not send the new field behaves exactly as before.
- New field
defaultFromLocalPartonGETandPUT /v1/email/settings(SDK:getEmailSettings,updateEmailSettings). It is the part of the From address before the@once the store sends from its own verified domain:infogives[email protected]. The default, and the value you get back when nothing is set, isnull, which sends from[email protected]exactly as before. - Allowed values: lowercase letters, digits,
.,-and_, up to 64 characters, starting and ending with a letter or digit, with no two dots in a row. Capital letters and surrounding spaces are normalised (" Info "is stored asinfo). Anything else answers400. Sendnullor an empty string to go back tonotifications. - It does not create a mailbox. The address is send-only, as
notifications@always was: customer replies go todefaultReplyTo, not to a mailbox at that address. - No effect without a verified domain. A store that has not verified a sending domain still sends from the shared Brainerce address, whatever this field holds. The value is kept, and applies the moment a domain verifies.
- It applies to every email the store sends from its own domain: order and account emails, campaigns, automated sequences, supplier purchase orders and the template test email.
- Also fixed: the template test email and automated sequence steps now leave from the store's verified domain too. Before, a store with a verified domain could still see them arrive from a Brainerce address.
2026-10-27 — One sale price rule, a 500-variant cap per product, and sanitised category and brand descriptions
Behaviour change on /v1 product writes, only for requests that carried a sale price that was not valid, or that would add the 501st variant. A call inside these rules behaves exactly as before.
- A sale price is above 0 and lower than the price it replaces, everywhere.
POST /v1/products,PATCH /v1/products/{id}, the variant routes (create, update, bulk save, duplicate), the bulk price and bulk edit routes and the MCP and assistant tools all answer 400 with a message that names the field (salePrice (100) must be lower than basePrice (100),variants[2].salePrice must be greater than 0). Before, creating a product with a bad sale price saved it without the sale and said nothing, updating one stored it, and0was stored as 0 by update and as "no sale" by create. To take a product or variant off sale sendsalePrice: null. A variant with nopriceof its own is compared with the productbasePrice. A bulk price change that would leave any product with a bad pair is refused whole, and that request writes nothing (an Edit All over a very large selection runs in batches of 200 products, so a refusal part way through leaves the earlier batches applied). - A stored product is never made un-editable. An update that does not change
basePrice,priceorsalePriceis not re-checked, so a product that already holds a sale at or above its price can still be renamed, restocked or republished. Changing the price of such a product is checked. - One product holds at most 500 variants.
POST /v1/productswithvariants,POST .../variants, duplicating a variant andbulkSaveVariantsanswer 400 when the call would take a product past 500. The plan has no variant limit; this is per product. A product that already holds more than 500 stays editable: only a call that raises its variant count is refused. - Category and brand
description(andtranslations[locale].description) are sanitised on write, with the same sanitizer a product description uses:<script>, event-handler attributes andjavascript:links are removed, ordinary formatting is kept, and plain text such asTom & Jerrykeeps its ampersand (a description with no HTML tag in it is plain text;<and>stay encoded as<and>, and sanitising twice changes nothing). A description that contains tags keeps the sanitizer's output, with&encoded as&. Descriptions already stored are not rewritten. Tags have no description.
2026-10-27 — Field limits on coupons, customers, gift cards and API keys: calls that used to answer 500 or save junk now answer 400
Behaviour change on /v1, only for requests that were already out of range. A call inside the limits below behaves exactly as before.
- Coupons (
POST /v1/coupons,PATCH /v1/coupons/{id}, the cart and checkout coupon calls):codeis trimmed of surrounding whitespace, must not be blank and is at most 100 characters (the same limit the checkout accepts);titleis at most 200 anddescriptionat most 2000.usageLimitandusageLimitPerCustomermust be whole numbers up to 2147483647 (a fractional value used to answer 500).value,minimumOrderAmountandmaximumDiscountare at most 1,000,000,000,000. Each targeting list holds at most 200 ids. - Bulk coupon calls (
POST /coupons/bulk/update,/coupons/bulk/deleteand the matching MCP tools) take at most 500 ids. A team member limited to certain sales channels now changes or deletes only the coupons published on their channels; ids outside that scope are skipped, like an unknown id. A bulkstartsAtorendsAtthat is not ISO 8601 answers 400. - Customers (
POST /v1/customers,PATCH /v1/customers/{id}and the address routes):emailis at most 320 characters,phone50, names 100,tagsat most 100 entries of 100 characters, and each address field has the limit the checkout address already had (line 255, city and region 120, postal code 32, country 64). A customer holds at most 100 saved addresses (400, codeCUSTOMER_ADDRESS_LIMIT). - Gift cards: an amount or adjustment above 9999999999.99 answers 400. It used to answer 500.
- API keys: a
tierhigher than the store's plan answers 400API_KEY_TIER_EXCEEDS_PLAN, arateLimitOverrideabove the plan ceiling answers 400API_KEY_RATE_LIMIT_EXCEEDS_PLAN, and a store holds at most 100 active keys (API_KEY_LIMIT). The dashboard already pinned the tier to the plan; the API did not.
2026-10-27 — Backorders (stock policy Allow / Notify) are honoured by the cart, the reservation, the checkout and the order
Behaviour change on the storefront and checkout surface, only for items whose merchant set backorderMode to ALLOW or NOTIFY. An item on the default setting (NONE) behaves exactly as before. Read this if you build a storefront.
inventory.canPurchasenow folds in the backorder setting. ATRACKEDitem onALLOWorNOTIFYkeepscanPurchase: trueat zero stock until itsbackorderLimitis reached (a null limit is no limit).inStockis unchanged and staysfalseat zero stock, socanPurchase && !inStockis a purchasable backorder.availableis real stock and is never negative on a storefront read. OnNONE,canPurchaseequalsinStockas before.- The cart, the reservation and the checkout use the same rule. A line on
ALLOWorNOTIFYis no longer flaggedOUT_OF_STOCK, can be reserved onON_CARTandON_CHECKOUTchannels, passes the stock check before payment and when the order is created, and refuses once the limit is reached (the limit counts units already on backorder). A variant follows its own setting. AKITis never backorderable, and an item whose stock another sales channel has reserved is not backorderable in the channels it is reserved against. - Nothing is added to the response shape. There is no new field and no new route, and Brainerce adds no "ships later" text: wording a backorder is the storefront's job.
- Read
canPurchase, notavailable > 0. A storefront that decides "sold out" from the stock count alone keeps showing "Out of stock" for a backorderable item that the cart and the checkout accept.
2026-10-26 — Wishlist routes and an ids filter on the public catalog read
Additive, on the storefront surface (/stores/{storeId}/... and /vc/{connectionId}/...). Nothing under /v1 changes and no existing call behaves differently. Read this if you build a storefront. The SDK side is client.wishlist and getProducts({ ids }), shipping in SDK 3.16.0.
- Five wishlist routes for a signed-in customer, in both modes (sales-channel mode needs the
customers:authscope):GET .../customers/me/wishlist/keys,GET .../customers/me/wishlist,POST .../customers/me/wishlist,DELETE .../customers/me/wishlist?productId=&variantId=andPOST .../customers/me/wishlist/merge. One list per customer, at most 200 items (HTTP 400code: WISHLIST_FULLwhen full). Every route answers a JSON body,DELETEincluded. Writes are throttled (30 per minute, merge 5 per minute). Only ids come back, and only for products that are currently visible: the list,keysand every count leave out a saved product the merchant has unpublished (it stays saved and returns when shown again). Turn the ids into products withidsbelow. A guest's list lives in the browser and is never sent to these routes until the guest signs in and the storefront callsmerge. See Optional features, Wishlist. idsonGET /stores/{storeId}/productsandGET /vc/{connectionId}/products. Comma-separated product ids, at most 100 distinct (more answers 400). An unknown, other-store or unpublished id is absent from the answer; an emptyids=returns no products, never the whole catalog. Sendlimit=100with it.GET /v1/productsdoes not take it.- What is not included: no share links, no price-drop or back-in-stock emails for saved items, no wishlist webhook events, no
/v1wishlist routes. The merchant sees counts of signed-in customers only, in the dashboard.
2026-10-25 — Shopify and WooCommerce sync and publish are retired: seven /v1 routes now answer as no-ops
Deprecation. Brainerce no longer syncs or publishes to Shopify or WooCommerce, and it never pushes coupons or metafields to any platform. The routes below still exist so existing callers do not break, but they do nothing. No removal date is set; one will be announced here with the 90-day notice described in Versioning. Read this if your integration calls any of them. (The Migration Tool, which imports products and orders from Shopify or WooCommerce once, is unaffected.)
| Route | Scope | What it answers now |
|---|---|---|
POST /v1/sync | sync:write | 501 NOT_IMPLEMENTED. There is no generic sync to trigger. To make a resource available on one of your storefronts use the per-resource publish-sales-channel routes |
GET /v1/sync/{jobId} | sync:read | 501 NOT_IMPLEMENTED |
POST /v1/coupons/{id}/sync | coupons:write | 200 with success: false and an empty jobIds; nothing is queued. 404 when the coupon does not exist |
POST /v1/coupons/{id}/publish | coupons:write | 200 with an empty jobIds; nothing is queued. The platforms array is still required but ignored |
GET /v1/coupons/platform-capabilities | coupons:read | 200 with an empty object {} |
GET /v1/metafield-conflicts | metafields:read | 200 with an empty array. No metafield conflict is ever created |
POST /v1/metafield-conflicts/{conflictId}/resolve and .../ignore | metafields:write | 404 for every id |
PUT /v1/metafield-definitions/{id}/platforms | metafields:write | 200. The platform list is stored on the definition, but nothing is pushed anywhere |
To limit a coupon to one of your storefronts use POST /v1/coupons/{id}/publish-sales-channel. The SDK methods triggerSync, getSyncStatus, syncCoupon, publishCoupon, getCouponPlatformCapabilities, getMetafieldConflicts, resolveMetafieldConflict, ignoreMetafieldConflict and setMetafieldPlatforms call these routes and behave the same way.
2026-10-25 — Per-channel stock at checkout, a pending payment is no longer failed, per-channel product status, and bot settings
Behaviour changes on the storefront checkout routes and additive fields on /v1/storefront-bot/settings. Read this if you build a storefront or a server that takes payment.
- Stock is judged per sales channel before any money moves. On a sales-channel storefront a product's
inventory.availableis what that channel may sell: the shared pool minus the units the merchant reserved for other channels, so two channels can show different numbers for the same product. On a channel that holds no stock before payment (reservationStrategyON_PAYMENT, the default),createCheckoutandcreatePaymentIntentanswer HTTP 400INSUFFICIENT_STOCKwithdetails.items[](productId,variantId,available,requested) when a line no longer fits, before a checkout row or a payment exists. Handle it oncreatePaymentIntentas well ascreateCheckout. A cart add or update for a variant line is now checked against the variant's own stock row even when its parent product has no stock record of its own (it used to count as unlimited). See Errors. - A
processinganswer fromPOST /payment/sdk-confirmis held, not recorded as failed. Some providers answerprocessingfor minutes while they settle. The route still answers400 Payment not yet confirmed (status: processing), so the contract and older SDKs are unchanged, but the payment is left untouched:GET /checkout/{id}/payment-statuskeeps sayingpendinguntil the provider's webhook settles it, where it used to flip tofailedon the first poll and let the buyer pay a second time. Keep polling; do not callcreatePaymentIntentagain because of it. See the payment return step. - A product can be switched off for one channel. The merchant can set a product to draft on a single sales channel while it stays published there. That channel's storefront reads, cart, checkout and Google cart transfer treat it as not published; the product and every other channel are untouched.
channelPublishesdoes not carry the status and no/v1call sets it. - Plan order cap.
createCheckoutanswers402 PLAN_ORDERS_EXHAUSTED(orSTORE_SUSPENDED) for a store that has reached its plan's order allowance, before any checkout or payment exists, andcreatePaymentIntentre-checks it for a checkout opened earlier. See Errors. - Storefront bot settings over the API.
PUT /v1/storefront-bot/settings(bot-settings:write) accepts, insidepersonaJson:languageswithrestrictLanguages(the bot answers only in the listed languages whentrue, off by default),storeFacts,knowledgePageIds(up to 5),rules(up to 20{ kind: 'always' | 'never' | 'handoff', text }) andmemory(off by default); and, beside it,escalationJson(enabled,defaultSubject,notifyEmails,handoverMessage),retentionJson({ days }, 7 to 3650: a daily job deletes older conversations and their messages for good; omit it to keep them forever) anddailySpendCeilingUsd.capabilities.cartRecoveryis deprecated and ignored. The public memory routes under/storefront-bot/{connectionId}/memoryare for the widget, not for a storefront integration. - SDK 3.14.1: the
brainerce/botwidget follows the shopper's language and the types above (PersonaConfig,BotEscalationConfig). Release notes ship in the package asCHANGELOG.md. A storefront on an older SDK keeps working; none of the fields above is required.
2026-10-22 — Outbound webhooks: missed events and resend, custom headers, body template
Additive change to the store-scoped webhook routes (/api/stores/{storeId}/webhook-subscriptions, dashboard and API key with webhooks:read / webhooks:write). One behaviour change: events that used to be dropped while a circuit was open are now kept. Read this if you receive Brainerce webhooks.
- Missed events are kept and can be resent. While an endpoint's circuit breaker is open, each event is recorded as Not sent in the endpoint's event log (up to 500 per endpoint, kept 7 days) instead of being dropped. There is still no automatic replay: nothing resends an event for you, and an event that failed all five attempts is not queued again either.
POST .../{id}/replay-missedresends the Not sent events, oldest first, at most 25 per call, 500 ms apart. The circuit must be closed and the endpoint active; 3 calls per hour. Response{ enqueued, eventIds, remaining }.POST .../{id}/redeliverwith{ "deliveryIds": [...] }(1 to 25 ids) resends chosen rows of the log, failed ones included. One id bypasses an open circuit, several respect it; 30 calls per hour.- Resent events keep their original event id (
X-Brainerce-Event-Id), carryX-Brainerce-Redelivery: true, are signed afresh, use the endpoint's current headers and body template, and are tried once. Test events (evt_test_*) cannot be resent. Dedupe on a business key (the order id) wherever a third-party API is called. - Deliveries list.
GET .../{id}/deliveries?outcome=all|succeeded|failed|missedreturnscounts: { all, succeeded, failed, missed }; each row hasstate(succeeded,failed,missed,resent,pending) andrenderedPayload. Each subscription row hasmissedCount. headerson create and update: name to value, at most 10 (names: letters, digits, hyphens; values: printable ASCII, up to 2048). Stored encrypted and never returned: reads giveheaderNamesonly. An update merges (nullremoves a name, an omitted name keeps its value).Host,Content-Length,Content-Type,Transfer-Encoding,Connection,Upgrade,TE,Trailer,Expect,Cookie,Set-Cookie,Keep-Alive,Accept-Encoding,Proxy-*andX-Brainerce-*cannot be set. Dashboard and API only: the admin MCP tools and the AI assistant cannot set or read header values.payloadTemplateon create and update (nullclears): a JSON object whose string values may hold{{path}}placeholders resolved against{ id, type, createdAt, data }, for example{{data.order.id}}. A value that is exactly one placeholder keeps its type; a placeholder inside longer text is interpolated as text. Up to 16 KB and 8 levels deep; no loops, conditions or expressions. POST and JSON only, one static URL, one request per event (no per-item fan-out), body capped at 1 MB, signed over the body actually sent. Brainerce does not read the response and has no fixed outbound IP.POST .../{id}/previewwith{ template?, deliveryId? }renders the body, destination and header names against a stored real event without sending anything (400when no event is stored yet).- Response bodies are not stored for an endpoint with custom headers or a body template (status code and time are kept), because a third-party response can contain codes or keys. Other endpoints keep the first 10 KB as before.
POST .../{id}/testnow takesconfirmLiveCalland answers400for an endpoint with headers or a template unless it istrue: a third-party API cannot tell a test from a real event.- See Webhooks: resending events, custom headers and body template.
2026-10-20 — Image alt text per language
Additive change to /v1/media and to every storefront product read. Nothing existing changes shape.
altTranslationson a media asset (MediaAsset,PATCH /v1/media/{id}): alt text per language, keyed by locale code ({ "he": "...", "fr": "..." }), ornull.altstays the default language, so a key for the store's default language is not stored (it is used asaltwhen you send none). The map you send replaces the stored one:nullor{}clears it and an empty string drops one language. Locale codes arehe,pt-BRstyle, up to 30 languages and 1024 characters each; anything else is a400.- Storefront reads pick the language for you.
images[].altandvariant.image.altare the entry for the requested locale (Accept-Languageor?locale=), falling back to the default-languagealt. The shape is still{ url, alt };altTranslationsis never sent to a storefront./v1reads, which carry no locale, return the rawaltTranslationson each image. - Alt edits now reach products.
PATCH /v1/media/{id}used to change only the asset's ownalt. A newaltoraltTranslationsis now also copied to every product and variant image that uses the asset. An image that carries its own custom alt for that product is left as it is. - Saving a product no longer drops image alt text. An update that sends an image without
altkeeps the stored alt, its source and its translations; sending a differentaltmarks the image as having its own custom alt. - SDK:
MediaAsset.altTranslations,UpdateMediaAssetInput.altTranslations, andProductImage.altTranslations/altSource(admin mode).
2026-10-19 — Checkout tax is re-verified before a charge, and three new payment errors
Additive change to createPaymentIntent and completeCheckout. Read this if you build a storefront or a server that takes payment.
expectedTotal(optional) oncreatePaymentIntent. Send the order total the buyer was last shown (checkout.total, a number in the store base currency). Tax is re-verified right before the charge; if the total is no longer that number the call answers HTTP 409CHECKOUT_TOTAL_CHANGEDwithpreviousTotal,newTotal,previousTax,newTaxandcurrency, and nothing is charged. Show the buyer the new total and retry withexpectedTotal = newTotalonce they accept. Without it the call behaves as before, except that the freshly verified total is charged (never a stale one) without asking the buyer to confirm a change. SDK 3.12.0.- HTTP 400
TAX_ADDRESS_REGION_REQUIRED. The store charges tax by state or province in the buyer's country (US, Canada, Australia) and the shipping address has noregion. Nothing is charged. Ask for the state or province, update the shipping address, and call again. The body carriescountry. - HTTP 400
TAX_NO_RATE_FOR_DESTINATION. Only for a store whose owner chose not to take orders to a country with no tax rate, and only when the buyer's country has none in force (a country the merchant marked "not collected" is never refused). Nothing is charged.createPaymentIntentanswers it, and so doescompleteCheckoutfor an order that needs no payment. The same address cannot succeed: ask for another delivery address. Off by default, so no existing store answers it until its owner switches it on. The body carriescountry. - HTTP 400
CHECKOUT_CART_CHANGEDfromcompleteCheckout, when the cart lines no longer add up to the checkout's quoted subtotal. No order is created; re-read the checkout (its totals are re-derived) and complete it again. appliesToShippingon tax rates (TaxRate,CreateTaxRateDto,UpdateTaxRateDto):falseleaves that tax component off the delivery cost (a province where GST applies to postage but PST does not). Absent ortrueis the previous behaviour.- SDK 3.13.0:
StoreInfo.ucp.profile, the Google Universal Cart profile a storefront serves verbatim at/.well-known/ucp. Absent unless the merchant switched Google Cart Transfer on for the channel; absent means answer 404. See Errors for every code above and the checkout rules.
2026-10-16 — Tax rates: effective dates, source and 409 on overlapping dates
Additive change to /v1/tax/rates. Read this if you write tax rates through the API.
effectiveFrom/effectiveTo(ISO 8601, both optional,nullon every rate that existed before). A rate is charged only while it isisActiveandeffectiveFrom <= now < effectiveTo; the end instant itself is not charged. A scheduled or expired rate is ignored by checkout, the estimate and the calculation. Sendnullon a PATCH to clear a date.effectiveFrommust be earlier thaneffectiveTo(400otherwise).- Changing a rate from a date. End the old rate first (
PATCHitseffectiveTo), thenPOSTthe new one with the same instant aseffectiveFrom. Two rates on the same country, region, postal code and tax class now coexist only while their windows do not overlap, so creating the successor while the old rate is still open-ended is a409. Rates without dates collide exactly as before. sourceandsourceVerifiedAton every rate: where the number came from (manual,preset:<KEY>,importfor any API-key write,assistant,mcp; set by the server, not accepted in a request) and when it was last checked (you may sendsourceVerifiedAt; a country preset stamps its own date). Informational: calculation never reads them.- SDK:
TaxRate,CreateTaxRateDtoandUpdateTaxRateDtogain the four fields.
2026-10-11 — Cart ownership proof (cartToken), cart error codes, and a one-time code in the recovery link
Additive change to the storefront cart routes (/vc/{connectionId} and storeId), with a new SDK cart store. Read this if you build a storefront, or any server, that writes to guest carts.
- A write to a guest cart carries proof of ownership. Send the cart's
sessionTokenas thecartTokenquery parameter (or theX-Cart-Sessionheader); a customer's cart is proven by that customer's Bearer token. It covers the cart item, clear, coupon, recalculate, refresh-snapshots, bundle and bump routes,POST /reservation/extendand/release,POST /checkoutand the checkout coupon routes. Reads (GET /cart/{id},GET /cart/session/{token}) need none, and the/v1API-key surface is unchanged. Each store has a mode: a new store enforces the proof from the start, and an existing store is only counted until Brainerce switches it. Under enforcement a missing or wrong proof is403 CART_ACCESS_DENIED(a 403, never a 401). See Cart ownership proof. - Cart 404s now carry a code.
CART_NOT_FOUND,CART_ITEM_NOT_FOUNDandVARIANT_NOT_FOUND, and cart add answersPRODUCT_NOT_FOUNDfor an unknown product. Status and message are unchanged. See Errors. - The recovery email link carries a one-time code. It is now
/cart?brainerce_cart=<cartId>#brainerce_recover=<code>, and a new route,POST /vc/{connectionId}/cart/recoverwith{ code, attemptId }, trades the code for the cart (with itssessionToken). The code is single use and valid for 10 minutes, the same code andattemptIdreturn the same cart for 120 seconds, every refusal is the same404, and the route is limited to 10 requests a minute. The SDK does the exchange itself and removes both parts from the address bar when the client is constructed, so do not read?brainerce_cartyourself. - SDK 3.11.0:
createCartStoreandbrainerce/react(CartProvider,useCart,useCartItemCount),BrainerceError.code,{ itemId }onsmartUpdateCartItemandsmartRemoveFromCart, a guest add in one request,cartTokensent automatically, andgetSmartCartItemCount()counting units. A storefront on an older SDK sends nocartToken: upgrade before your store is switched to enforcement. See the cart store.
2026-10-11 — Order shipping rates: quoted, and rate ids that name their shipping app
Additive change to GET /v1/orders/{id}/shipments/app-rates. Read this if you buy shipping labels through the API.
quoted. Each rate may carryquoted: false. It marks a carrier that has no rate API (the price is a contract between the merchant and the carrier, such as HFD in Israel): it is listed last, itspriceis a placeholder that must not be shown or summed, and itsidbuys a label without a quote. A real quote omits the field, so existing code that showspriceis correct for every rate it showed before.- Rate ids name their app. A store can now install more than one shipping app, so a rate
ididentifies the app that issued it. Ids were already opaque and had to be passed back whole toPOST /v1/orders/{id}/shipments/app-label; nothing changes if you did that. Checkout rate ids keep theircarrier:prefix. - SDK: the
getOrderShippingRates()return type gainsquoted?: boolean.
2026-10-09 — Customization uploads: a per-field print-quality setting, fieldKey, and a 10 MB ceiling
Additive change to the customization upload and to metafield definitions. Read this if your storefront lets buyers upload a photo into an IMAGE or GALLERY customization field.
- Print quality is a per-field setting. A metafield definition has a new boolean
keepOriginalImage, defaultfalse, and it only applies to customer-inputIMAGEandGALLERYfields. Off: a buyer's upload is a regular image, converted to WebP and shrunk to at most 2048 px, exactly as before. On: the file is kept at the pixel size and in the format the buyer sent, so the merchant can print it. Create and update accept it, the/v1definition responses return it, and setting it totrueon any other kind of field is a400. Every existing field is off, so nothing changes until a merchant turns it on. - Send
fieldKeywith the upload.POST /vc/{connectionId}/customization-uploadandPOST /stores/{storeId}/customization-uploadtake an optionalfieldKeyquery parameter: thekeyof the customization field the file is for. The server decides from it, so an upload that omits it is stored as a regular image even when the merchant switched print quality on, and the response does not tell you. A key that matches no active customer-inputIMAGEorGALLERYfield is not an error; a malformed one is a400. - The size ceiling is 10 MB (was 5 MB), still with a 40 megapixel limit. Review photos stay at 5 MB. EXIF, including GPS, is removed from every upload and its rotation is applied to the pixels, with print quality on or off.
- SDK:
uploadCustomizationFile(file, { fieldKey })andMetafieldDefinition.keepOriginalImage(3.10.0). On 3.9.x the second argument is a type error. See the customization upload rules.
2026-10-07 — Reviews switched off: a REVIEWS_DISABLED error code, and reviewsEnabled on /info
Error-code and response change on the storefront review routes. Read this if you build a storefront and the merchant can turn reviews off.
- The refusal now carries a code. While the merchant has product reviews switched off, every storefront review route (list, submit, edit, delete, photo upload) answers
403withcode: "REVIEWS_DISABLED". Before, the code was the genericFORBIDDEN, which the origin check and a suspended store also use, so a storefront could tell "reviews are off" from a real failure only by matching the message text. Branch onerror.details.code; the message is unchanged.getMyProductReviewis the exception: it answers200witheligible: falseandreason: "reviews_disabled". An earlier version of the Product Reviews rules said the403carriedreason: 'reviews_disabled'; it never did. reviewsEnabledonGET /info. Both/infoendpoints (storeIdandsalesChannelIdmode) now returnreviewsEnabled: boolean, so a storefront learns the switch without making a failing call. When it isfalse, render no reviews UI at all, including the star rating on product cards andaggregateRatingin structured data: a product keeps its storedavgRatingandreviewCountwhile reviews are off. Absent means an older backend; treat it as on. InstoreIdmode the value can lag a change by up to five minutes.- SDK:
StoreInfo.reviewsEnabled(3.9.0). Stores scaffolded withnpx create-brainerce-store1.89.0 read it. See Errors and the Product Reviews rules.
2026-10-05 — payment.failed is no longer sent for a checkout that is already paid
Behaviour change in webhook semantics. Read this if you alert on payment.failed.
A payment provider can deliver a failure notice late, twice, or out of order, for example a declined first attempt that arrives after the retry succeeded. Brainerce used to mark the checkout FAILED anyway and send you payment.failed, even though the order existed and was paid. The checkout status poll could then answer failed next to an orderId, and the shopper could be offered a second payment.
A checkout that already has an order, or whose payment is captured, is now left as it is: its status does not change, no payment.failed webhook is sent, and a repeated notice is ignored. payment.failed still fires for a genuine decline on an unpaid checkout.
Two smaller changes on the storefront payment-intent call (POST /vc/{connectionId}/payment/intent): a retry of a sandbox checkout now returns the same intent instead of 409 checkout_claim_failed, and a payment-provider app rejecting the request with a client error (for example a missing terminal on a first-time setup) now releases the checkout so the shopper can retry once the store fixes it, instead of locking it. A timeout or a 5xx from the provider still leaves the checkout locked, because the provider may already have created a payment.
2026-10-05 — Checkout completion retries: PAYMENT_REQUIRED is no longer replayed; lock contention keeps its code
Behaviour change on POST /v1/checkout/{checkoutId}/complete and two 503 paths. Read this if you retry completion with the same Idempotency-Key.
400 PAYMENT_REQUIREDis no longer stored against the key. It means the payment has not captured yet. It used to be replayed for 24 hours, so a retry with the same key after the payment landed kept returning the stale error. A retry with the same key now runs again and returns the order. Every other4xxis still stored and replayed.- Lock contention on completion and on refunds now returns
code: "SERVICE_TEMPORARILY_UNAVAILABLE". The2026-09-10entry below promised this code, but these two paths still answered withINTERNAL_SERVER_ERROR. The status (503), the message and the behaviour are unchanged, and nothing was processed. Retry the identical request after a short backoff. - SDK:
completeCheckout(checkoutId, { idempotencyKey })now sends the header in API-key mode. Previously the call sent none and always failed with400 MISSING_REQUIRED_FIELDS. See Idempotency.
2026-10-05 — GET /v1/products now honours status and type; /v1 auth header clarified
Behaviour change on an existing route. Read this if you list products with an admin key.
GET /v1/products always listed status and type as query parameters, but ignored both: every product came back whatever you passed. Both now filter:
status—activeordraft. Omit it to get both.type—SIMPLE,VARIABLEorKIT.
Any other value, including a different case (ACTIVE, simple), is now a 400 instead of being silently ignored. The SDK forwards status in apiKey mode only (getProducts({ status: 'draft' })), and type in apiKey and storeId modes.
Auth, documentation fix only. Earlier versions of these docs said /v1/* also accepted X-API-Key: brainerce_…. It never did: /v1/* reads Authorization: Bearer brainerce_… only, and a request with just X-API-Key is a 401. Nothing changed on the server.
2026-09-10 — Adding to a storefront cart can now return 400 CART_LINE_LIMIT_REACHED
New error code, and a 400 that some routes could not previously return. Read this if you build a storefront.
A shopper-facing cart holds at most 50 distinct line items. That limit already existed, but only on the single-request POST /vc/{connectionId}/guest-checkout body — a cart assembled one addToCart at a time was unbounded, and the public storeId-mode storefront was never checked at all.
The check now runs on every add-to-cart route an anonymous shopper can reach: the three stores/{storeId} storefront routes (add to cart, bundle, order bump) and the four /vc/{connectionId} equivalents. An add that would create a 51st distinct line returns:
{
"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 counts distinct lines, not quantity: quantity: 200 on one product is fine, and re-adding a product already in the cart raises its quantity rather than creating a line. It is evaluated only when a new line would be created, so a cart at the limit can still have quantities changed and lines removed.
Admin API keys are not capped. A brainerce_* key building a B2B order with hundreds of SKUs is unaffected, as are the dashboard and assistant paths. The cap is storefront traffic only: vc_* and storeId callers.
If you are affected: switch on code and render a "your cart is full, remove something to add this" state rather than a generic error toast, and keep the item the shopper tried to add on screen so one tap retries it. details.maxLines carries the number, so nothing has to parse it out of the sentence. The message wording is deliberately unchanged, so an older integration matching that sentence keeps working — keep it as a fallback while you may be talking to a backend that predates the code.
⛔ Bundle and order-bump adds are not transactional. A bundle whose lines would cross the limit throws part-way and leaves the lines it already added in the cart, so re-read the cart after a failed bundle add rather than assuming it rolled back.
See Error Catalog and Cart limits.
2026-09-10 — A transient internal failure no longer reports itself as a payment-provider problem
New error code. Non-breaking, but read it if you switch on code.
Some requests take a short-lived internal lock before doing work that must not run twice. When that lock store is unreachable, the request is refused rather than run without the guarantee — a 503 that has always processed nothing.
That refusal was shipping code: "PROVIDER_NOT_READY", which says a payment provider is missing or not responding. It reaches, among other places, a shopper opening a digital file they have already paid for — where the honest answer is "try again in a moment", not "your payment provider is down".
It now returns code: "SERVICE_TEMPORARILY_UNAVAILABLE". The status, the message and the behaviour are unchanged. Retry the identical request after a short backoff; changing the request will not help.
2026-09-10 — Checkout completion's fail-closed 503 gets its own code
New error code. Non-breaking, but read it if you switch on code.
POST /v1/checkout/{checkoutId}/complete is the only route where Idempotency-Key is mandatory, and it fails closed: if the once-only lock cannot be taken, the request is refused rather than run unguarded. That refusal has always been a 503 and has always processed nothing — no order, no charge — but it was shipping code: "IDEMPOTENCY_KEY_REUSED".
That code is documented as "the same key arrived with a different body — generate a new key". On a transient 503 that is exactly the wrong instruction: rotating the key discards the double-charge protection you asked for and sends an unguarded second attempt at the same payment.
The refusal now returns code: "IDEMPOTENCY_LOCK_UNAVAILABLE". The status, the message and the behaviour are unchanged.
If you are affected: only if you branch on code for this route. Retry the identical request with the same Idempotency-Key after a short backoff. IDEMPOTENCY_KEY_REUSED keeps its meaning and still arrives as a 409 — for a genuinely reused key, and for a concurrent request still holding one. See Idempotency and the Error Catalog.
2026-09-07 — PATCH /vc/{connectionId}/customers/me returns the profile, not the whole record
Security fix, effective immediately
The storefront profile update echoed the store's full customer record back to the shopper's own browser. Alongside the profile fields it published, the response also carried merchant-side annotations: tags (segmentation labels), metadata (arbitrary merchant JSON such as ERP ids), totalSpent, totalOrders and lastOrderAt, platformConnections (Shopify, WooCommerce and TikTok customer ids), loyaltyMembershipId, acquisitionSalesChannel and channelPublishes (internal sales-channel ids and names).
The matching GET /customers/me had always returned a narrow projection, so the read was tight while the write returned everything. The documented schema never listed those fields, which means the endpoint was returning more than its own published contract.
The response is now exactly what the schema documents: id, email, firstName, lastName, phone, hasAccount, emailVerified, acceptsMarketing, birthMonth, birthDay, role, addresses, createdAt, updatedAt. It stays deliberately wider than the GET, because hasAccount, acceptsMarketing and role let storefront code build role-gated features after a save.
If you are affected: only if you read a field outside that list. No field in the published schema changed, and nothing in our SDK or starter templates read the removed ones.
2026-09-07 — Three responses that disagreed with their own documentation
Fixed. One of the three is a breaking type change; read the first section.
total on checkout completion is now a decimal string
POST /v1/checkout/{checkoutId}/complete returned total as a JSON number (46.96). Its sibling POST /v1/orders returns the same money as a decimal string ("46.96"), and so does every other money field we publish: unitPrice, subtotal, shippingAmount, a shipping rate's price, a gift card's amount. A client with one typed order model broke on whichever of the two routes it had not tested against.
total is now the decimal string "46.96" on checkout completion, matching POST /v1/orders and the rest of the API. The value is taken straight off the stored decimal, so it never passes through a float on the way out and cannot lose a cent. orderId, orderNumber, status and message are unchanged.
If you are affected: anything doing arithmetic on total from this route needs parseFloat(response.total). Code that only displayed the value, or sent it onward as text, keeps working. The change applies to every mode of the route, so the storefront and vibe-coded storefront paths return the string too.
The SDK declares CompleteCheckoutResponse.total as string, alongside the orderNumber, status and message that the route always returned and the type never mentioned. An older SDK compiled against orderId alone is unaffected.
GET /v1/products/{id}/inventory documents everything it returns
The endpoint was documented as returning { available, reserved, total }. It has always returned the full inventory item: id, productId, trackingMode, backorderMode, backorderLimit, lowStockThreshold, lastInventorySyncAt and updatedAt as well. Its PUT sibling already published that shape, under the same inventory:* scope family and the same admin-key credential, so nothing new is exposed here and no field was taken away.
Nothing about the response changed. The reference now describes it, and the SDK adds ProductInventoryResponse in place of the three-counter inline type on getInventory.
⛔ Two shapes come back with a 200. A product with no inventory row, and an id this key cannot see, still reads back as { "available": 0, "reserved": 0, "total": 0 } with none of the fields above. That is unchanged and is not a 404. Branch on id being present rather than waiting for an error. The 404 that the reference used to list for this route was never produced and has been removed.
A dashboard route that answered "not found" with a 200
Included for completeness rather than because it is versioned: GET /stores/{storeId}/email/logs/{logId} is an internal dashboard route, not part of the /v1 contract. A missing log produced { "error": "Log not found" } with HTTP 200, so a caller reading the documented log fields got undefined and no signal that anything had failed, while the reference advertised a 404 the handler never sent. It now returns a real 404 in the standard error envelope with code RESOURCE_NOT_FOUND.
2026-09-07 — compatible-providers no longer returns installation secrets
Security fix, effective immediately
GET /v1/regions/{regionId}/compatible-providers returned each matching payment provider as the raw AppInstallation row. Alongside the fields a caller needs, that row carries webhookSecret (a live HMAC signing key), encryptedSecrets (the encrypted provider credentials), tokenHash and config. All four reached any caller holding a regions:read key.
The response is now exactly { id, appId, app: { id, name, slug } | null }, which is the shape the dashboard and the SDK already declared. Every other field is gone, including the four above.
If you are affected: you are only affected if you read a field outside that list. Nothing in our SDK or starter templates did. id is unchanged and is still the AppInstallation id you pass when setting a region's providers.
Rotate anything you consider exposed. If you hold a regions:read key that a third party could have used against your store, treat that store's payment-app webhook secrets as disclosed and rotate them.
2026-09-06 — The KIT product type, and how a kit is priced
Added
ProductTypegained a third member:KIT, alongsideSIMPLEandVARIABLE. A kit ("maaraz") is one purchasable product assembled from other catalog products — a gift box with a bottle and two glasses. It is bought as a single cart line using the kit's ownproductId, and the server reserves each component behind that one line. ⛔ Never add the components as separate lines: that charges the customer twice and reserves the stock twice.GET /v1/products/{id}/kit-components— the kit's contents with each component's live price and stock, plus the resolved kitprice, its per-componentallocationMinorsplit, thepricingMode,discountValue, andavailable(how many kits can be sold). A product that is not a KIT returns an empty, unsellable shape rather than a 404, so callers need not branch on product type. (products:read)PUT /v1/products/{id}/kit-components— replace the kit's component list in one transaction and get it back re-resolved. This is a full replace, not a patch: send the list the kit should end up with. It also carries the optionalpricingModeanddiscountValue. Refused: a product that is not a KIT, a component from another store, a component that is itself a KIT, aVARIABLEcomponent with no variant pinned, a variant that does not belong to its product, a duplicate slot, the kit itself, and a component whose product or pinned variant is not published (checked over the whole list you send, not only the rows you changed). Max 30 components,quantity1-2000 each. (products:write)POST /v1/productsandPATCH /v1/products/{id}acceptkitPricingModeandkitDiscountValue. Both are ignored onSIMPLEandVARIABLEproducts, and omittingkitPricingModeon an update leaves the kit's current mode alone rather than resetting it.
Three pricing modes, and two of them move on their own
kitPricingMode | What the kit charges |
|---|---|
FIXED (default) | The kit's own basePrice / salePrice. Unchanged when a component reprices. |
SUM | Exactly what the components cost, recomputed on every read — a component going on sale lowers the kit price by itself. |
SUM_MINUS_PERCENT | That sum, less kitDiscountValue percent (0-100). |
⛔ Outside FIXED, the basePrice on the product row is a PLACEHOLDER. Do not render it and do not compute from it: use the resolved price returned by the by-slug product read or by GET /v1/products/{id}/kit-components. The kit's own salePrice is ignored on the two SUM modes, because the discount is already expressed by the mode and honouring both would apply it twice. Checkout recomputes the price authoritatively and snapshots the result onto the cart line.
Reading a kit on the storefront
- A kit carries no
inventoryobject. ReadkitAvailableinstead:null= unlimited,0= not sellable (including a kit with no components), any other number = that many kits. It is whichever component runs out first, so it can drop without the kit itself being edited. Treating a missinginventoryas "in stock" renders a sold-out kit as buyable. kitComponents({ productId, variantId, name, sku, quantity, image }) comes back on the single-product (by slug) read only, never on list responses. It is display only — render it so the shopper sees what is in the box; do not turn the rows into cart lines.- A kit takes no
variantId(it has no variants, and sending one is rejected) and no modifierselections(HTTP 400 — a kit is a fixed recipe). An out-of-stock component fails the add withINSUFFICIENT_STOCKnaming what ran out.
Structural rules: a kit cannot contain another kit; a VARIABLE component must have exactly one variant pinned; a kit with no components is not purchasable; a kit cannot have variants of its own; SIMPLE ↔ KIT conversion is allowed (KIT → SIMPLE only once the kit is empty), VARIABLE ↔ KIT is refused outright. Kits are not produced by any CSV or AI import path, and are not sent to Google or Meta product feeds.
SDK: getKitComponents(productId) and setKitComponents(productId, { components, pricingMode?, discountValue? }). See the SDK reference and Kits in the integration rules.
2026-09-05 — Gift cards over the admin API
Added
Eight admin routes, API key only. Each is scoped separately, so a reporting integration can read balances without ever being able to mint one.
GET /v1/gift-cards— list, paginated.searchmatches the last four characters of a code or part of a recipient email; a full code cannot be searched, because only an HMAC of it is stored. (gift_cards:read)GET /v1/gift-cards/liability— the month-end figure, per currency, never one cross-currency total. Also returnsenabled, the per-store gift-cards switch. (gift_cards:read)GET /v1/gift-cards/{id}— one card with its full ledger. The code is not in the response and never will be. (gift_cards:read)POST /v1/gift-cards— mint a card.amountis a decimal string,noteis required and permanent, and optionalexpiresAt(ISO 8601) must be in the future; omit it for a card that does not expire.plaintextCodeis in this response and nowhere else, ever: persist or deliver it before you discard it. (gift_cards:issue)POST /v1/gift-cards/{id}/reissue— new code, whole balance moved, old card revoked. The originalexpiresAtcarries forward, so this cannot be used to restart an expiry clock that may carry statutory notice duties. (gift_cards:issue)PATCH /v1/gift-cards/{id}/adjust— signed decimal string:"25.00"credits,"-25.00"debits. A debit cannot take the balance below what a checkout in progress is already holding.noterequired. (gift_cards:adjust)PATCH /v1/gift-cards/{id}/status—ACTIVE/DISABLED/REVOKED. None of them touches a hold a checkout is already carrying, because pulling value out from under a shopper mid-payment would strand a provider charge already in flight. (gift_cards:write)PATCH /v1/gift-cards/bulk/status— up to 200 ids,ACTIVEorDISABLEDonly.REVOKEDis deliberately refused in bulk: it belongs to re-issue, which moves the balance to a replacement first. (gift_cards:write)
The four scopes are separable on purpose and none is implied by gift_cards:read. ⛔ A key carrying gift_cards:issue or gift_cards:adjust creates and rewrites stored value: it is a server credential, and it must never reach a browser.
No delete, and no way to change an expiry after issuance. The ledger is append-only and nothing removes a card. expiresAt is write-once, set when the card is minted. There is also no expiry job: a lapsed card is refused when it is read, at POST /v1/gift-cards/balance and again when a checkout tries to hold value on it, and its balance is reported as expired by GET /v1/gift-cards/liability rather than written off. A card whose expiresAt falls inside the checkout's own 30-minute window is refused when it is applied, so a card stops working shortly before its stated date.
See Gift cards (administration) in the SDK reference.
2026-09-03 — Gift cards at checkout
Added
POST /v1/checkout/{checkoutId}/gift-card— apply a gift card to a checkout. Body is{ code }(4-64 chars; case, spaces, dashes and the confusable lettersI/L/O/Uare normalised for you). Returns{ tenderId, amountApplied, providerAmountDue }. SupportsIdempotency-Key. Rate limited to 5/min, tighter than the coupon route beside it, because a gift-card code is bearer value. (checkout:write)DELETE /v1/checkout/{checkoutId}/gift-card/{tenderId}— remove one card, by tender id, never by code: a checkout can carry several cards and the code is never echoed back. The held value goes straight back to the card; nothing was debited while it was applied. SupportsIdempotency-Key. (checkout:write)POST /v1/gift-cards/balance—{ code }in,{ balance, currency, usable }out.POST, notGET, so a bearer code never lands in an access log or browser history. Rate limited to 5/min. (checkout:write)
Changed
- A checkout now carries
tenders({ tenderId, amountApplied }[], oldest first) andproviderAmountDue.totalis deliberately unchanged by a gift card — a card is a means of payment, not a discount, so the order is still worth what it is worth and tax is still calculated on that.providerAmountDueistotalminus every applied card, and equalstotalwhen there are none. Existing clients that ignore both fields are unaffected. Render the card as its own line below the total, never folded intodiscountAmount. POST /v1/checkout/{checkoutId}/completenow succeeds with no captured payment when gift cards cover the whole order (providerAmountDueis"0.00"). The amount owed is derived server-side from the holds the platform itself placed, never from the request, and the order created is a real paid order rather than a test one. Every other completion still requires a captured payment.
Every refusal is the same answer, on purpose. An unknown code, an expired card, a spent one, a disabled one and one in the wrong currency all return the same 400 with the same message — That gift card code cannot be used on this order. — and POST /v1/gift-cards/balance returns { "balance": "0.00", "usable": false } for all of them, in the same amount of time. A response that distinguished them would be an oracle for discovering real codes. There is no GIFT_CARD_EXPIRED / GIFT_CARD_NOT_FOUND / INSUFFICIENT_BALANCE code; do not branch on a reason you cannot learn. A card also pays only in its own currency — there is no conversion.
Sales-channel and storefront modes get the same three routes under /vc/{salesChannelId}/… and /stores/{storeId}/…, and getStoreCapabilities() (sales-channel mode only) gained features.hasGiftCards — a per-store switch, not a count. Issuing a card is not on the storefront or sales-channel surfaces at all: it is a permission-gated merchant operation, so a /vc or /stores client can redeem a card but never create one. It is available on /v1 with an admin API key carrying gift_cards:issue, which arrived separately; see the entry above. See the SDK reference and Optional Features, Task 19.
2026-08-27 — Photos on product reviews
Added
PATCH /v1/review-images/{id}/hide— take ONE photo off a review, leaving the review text and its other photos published. Use this instead of hiding the whole review when only a picture is the problem: hiding the review also drops its rating out of the product average. Takes the image id from a review'simages[].id, not the review id. (reviews:write)PATCH /v1/review-images/{id}/show— publish one review photo. This is also the approve action: on a store that checks photos before publishing, a photo carries noapprovedAtuntil someone shows it. (reviews:write)
Changed
GET /v1/products/{productId}/reviewsnow returnsimageson every review — every photo including ones hidden or still awaiting approval, since this is the moderation view. Each carriesid,url,thumbnailUrl,width,height,approvedAt(null = waiting on the merchant) andhiddenAt. Existing clients that ignore the field are unaffected.POST /v1/products/{productId}/reviewsaccepts an optionalimageKeysarray (max 5). This is the import path for migrating reviews that already have photos: upload the images through the normal media endpoints first, then pass their keys. Keys must belong to the same store. Photos imported this way publish immediately regardless of the store's approval setting, because a server-to-server caller has already vetted them.
Storefront and sales-channel modes get the customer-facing half of this — an upload endpoint gated on purchase eligibility, and a photos policy object telling the storefront what it may offer. See the SDK reference and the integration guide.
Three new Store settings control it: reviewsEnabled (which existed but had no merchant UI until now), reviewPhotosEnabled, and reviewPhotosRequireApproval. The last defaults to false, matching how review text already behaves on this platform — publish immediately, moderate afterwards. It is evaluated when a photo is written rather than when it is read, so turning it on never retroactively un-publishes photos shoppers have already seen.
2026-08-22 — Bulk product creation
Added
POST /v1/products/bulk— queue a batch of products and get ajobIdback immediately (202 Accepted). Every fieldPOST /v1/productsaccepts is accepted per row, including variants, categories, brands, tags, images, translations and tax behaviour. Cap is 1000 products per request, 500 recommended. (products:write)GET /v1/products/bulk/{jobId}— counters for one batch: total, processed, succeeded, failed, skipped, pending, and a status ofQUEUED/RUNNING/COMPLETED/COMPLETED_WITH_ERRORS/FAILED/CANCELLED.skippedrows are neither failures nor creations.COMPLETED_WITH_ERRORSmeans every row was attempted — read the per-row errors rather than retrying the batch wholesale. (products:read)GET /v1/products/bulk/import/{importId}— one set of counters rolled up across every batch that shared animportId. This is how a 3,000–50,000 product catalog is sent: chunk it into several requests carrying the sameimportId, then poll this once instead of polling eachjobId. The aggregate status is the least-complete chunk, andfinishedAtstays null until all of them finish. (products:read)GET /v1/products/bulk/{jobId}/errors— paginated, one entry per failed row, carrying the 1-indexedrowfrom your submitted array plussku,externalId,productName, acode(VALIDATION/DUPLICATE/PLAN_LIMIT/INTERNAL) and the message. Nothing is truncated. (products:read)
Two independent dedup layers, because a catalog import is the operation people retry days later:
- The
Idempotency-Keyheader or anidempotencyKeyfield in the body returns the ORIGINALjobIdon a re-send. The body field is stored in the database rather than Redis, so it outlives the header's 24-hour window — and thebulk_create_productsMCP tool has no headers to carry one. - Row-level: a row whose
skuorexternalIdalready exists in the store is skipped, not created again (conflictStrategy: 'skip', the default;'error'fails the row instead). This is a database check, so it holds regardless of elapsed time, chunking, or whether a key was sent. A row with neither askunor anexternalIdhas nothing to match on and will be duplicated by a re-send outside the key window — setexternalIdon SKU-less rows.
See Idempotency → Bulk imports for the full retry semantics.
2026-08-11 — Spreading a resolved address no longer 400s the whole checkout
Fixed — SDK 1.53.0, no API change
setShippingAddress()andsetBillingAddress()now droplat,lngandformattedAddressfrom the request body instead of forwarding them. Spreading the address you got fromgetAddressDetails()straight into the call — the shape the docs themselves showed — used to send those three fields, and the endpoints validate against a strict allow-list: one unrecognized property rejects the whole call with400 "property lat should not exist, property lng should not exist". That does not degrade gracefully; it blocks checkout on every address. Note TypeScript did not catch it either — excess-property checks do not apply to spreads.- Nothing else is stripped: a misspelled field still reaches the server and still fails loudly. The first strip per client logs one
console.warn.
Unchanged — read this if you call the REST API directly
- The strip lives in the SDK, not in the API.
PATCH /v1/checkout/{checkoutId}/shipping-addressand.../billing-addressstill reject every property outside their documented payload, includinglat/lng/formattedAddress. Build the body from the documented fields rather than spreading a resolved address into it. - Coordinates are still never accepted from the caller, by design — see the 2026-08-10 entry below.
placeIdis how exact coordinates reach zone matching;address.lat/address.lngare for your own UI (a map pin, a distance readout).
2026-08-10 — One zone per address, and exact coordinates for map-drawn zones
Changed — affects the shipping rates your checkout displays
- An address now matches exactly ONE shipping zone, and only that zone's rates are returned. Previously every zone covering the address contributed its rates, so a shopper inside a small area drawn within a larger zone was offered both zones' prices side by side — while the zone editor's own "Priority (lower = first)" field promised a "first" that nothing ever selected (
prioritywas only anORDER BY). The winner is decided by, in order: lowestpriority; then a zone matched by its drawn shape over one matched only by its country list; then the smaller drawn area; then zone id, purely so the result is deterministic. This matches WooCommerce (the first zone in sort order wins outright) and Shopify (which forbids overlap structurally). - Stores whose zones don't overlap see no change — only one zone ever matched such an address. If you deliberately relied on several zones' rates being merged, put those rates on a single zone instead.
availableShippingRateson a checkout,GET /v1/checkout/{id}/shipping-rates, and the dashboard rate calculator now all follow the same rule. The calculator additionally used to ignore zone region restrictions entirely, so it could show rates checkout would never offer.
Added
PATCH /v1/checkout/{checkoutId}/shipping-addressacceptsplaceIdandplaceSessionToken.placeIdis the id of the autocomplete suggestion the shopper picked (fromGET /vc/{connectionId}/checkout/address-suggestions; the/v1surface has no autocomplete route); the server re-resolves it to that address's exact coordinates and matches map-drawn ("polygon") shipping zones against them.placeSessionTokenis the token used for those autocomplete calls and is optional — the resolved place is cached server-side for 24h byplaceId, so the usual autocomplete → address-details → set-address flow hits that cache and incurs no extra billed lookup.- SDK:
setShippingAddress()accepts the same two fields.
Why it matters
- Without
placeIdthe server has to geocode the submitted address lines instead. That is materially less precise for street addresses, and the failure is silent and directional: a same-named street in a neighbouring city can outrank the right one, so the shopper is quoted a different area's rate, or told there is no delivery at all while the store does in fact cover them. If the store draws its delivery areas on a map and you use the address autocomplete, sendplaceId. Stores whose zones are country/region/postal-code based are unaffected.
Not added, deliberately
- There is no
lat/lngfield on this endpoint and there will not be one. Zone matching selects which shipping rate is offered and charged, so coordinates are only ever resolved server-side from aplaceId— never accepted from the caller. (Sending them anyway is a hard400; as of SDK 1.53.0 the SDK strips them for you — see the 2026-08-11 entry above.)
2026-08-09 — Order shipments, live carrier rates, and the rate the shopper paid for
Added
- Two read routes under an order's shipments, alongside the existing
POST /v1/orders/{id}/shipments/app-label:GET /v1/orders/{id}/shipments/app-rates— live carrier rates for an existing order, from the merchant's installed App Store shipping app. This call is what creates the shipment at the carrier, so the returnedidis opaque and short-lived — quote immediately before you buy, and never parse the id. Returns[]when no shipping app is installed or the order has no usable shipping address. (orders:read)GET /v1/orders/{id}/shipments— shipments recorded against the order, each withtrackingNumber,trackingUrl,labelUrl,labelFormat,rate/rateCurrency, and its carrier trackingevents(newest first, capped at the 200 most recent). Tracking arrives by carrier webhook; read this for display, never poll it to drive state. (orders:read)
POST /v1/orders/{id}/shipments/app-labelacceptslabelFormat(PDF|PNG|ZPL|EPL, defaultPDF) andcustomsContentsType(merchandise|gift|documents|sample|return, cross-border only). The response gainedlabelFormat— carriers that cannot produce the requested format return their closest match rather than failing the purchase.OrderSummaryDto— returned verbatim byGET /v1/ordersandGET /v1/orders/{id}— gainedshippingSelection:{ carrier, service, methodName, amount }, the live carrier service the shopper chose and paid for, ornullfor flat-rate/zone shipping, pickup, and imported orders. Match it against a freshapp-ratesquote oncarrier+service(case- and whitespace-insensitive) before buying a label; the rate id does not survive a re-quote. Admin surface only — buyer-facing order endpoints do not return it.- The same DTO now actually returns
trackingNumber,trackingUrl, andcarrier. They were declared on the SDK'sOrdertype but never populated on this route. - SDK methods:
client.getOrderShippingRates(orderId),client.getOrderShipments(orderId).
Changed
availableShippingRateson a checkout narrows live carrier rates to at most three — the cheapest, the fastest, and one genuinely in between when it earns its place. A quote that previously returned seven near-identical services now returns two or three. Each carrier rate carriesspeedTier(cheapest|balanced|fastest), derived per quote rather than mapped from service names; render that andestimatedDaysrather thanname, which is the carrier's own service code. Manual zone rates pass through unchanged and carry no tier.
2026-07-15 — Storefront Bot settings and conversations on the public API
Added
- The AI Shopping Assistant (Storefront Bot) — name, avatar, persona (greeting, tone, starter questions, capabilities), and guardrails (
avoidTopics,customInstructions) — was previously dashboard-only. It's now reachable with an API key under/v1/storefront-bot/*:GET /v1/storefront-bot/settings— every connection on the store + its current (or default) settings. (bot-settings:read)PUT /v1/storefront-bot/settings— create/update one connection's settings. Only the fields you send are changed. Available on every plan. (bot-settings:write)GET /v1/storefront-bot/conversations— paginated Studio inbox listing, optionally filtered to one connection. (bot-conversations:read)GET /v1/storefront-bot/conversations/{id}— full transcript, including a stored summary if one was generated. (bot-conversations:read)POST /v1/storefront-bot/conversations/{id}/summarize— summarize a conversation on demand and persist it. Short threads (10 or fewer messages) are a graceful no-op — no credits charged. (bot-conversations:write)
- Four new API-key scopes:
bot-settings:read,bot-settings:write,bot-conversations:read,bot-conversations:write. - SDK methods:
client.getBotSettings(),client.updateBotSettings(data),client.listBotConversations(opts),client.getBotConversation(id),client.summarizeBotConversation(id). - MCP tools:
get_bot_settings,update_bot_settings,list_bot_conversations,get_bot_conversation,summarize_bot_conversation.
Typical flow
const { connections } = await client.getBotSettings()→await client.updateBotSettings({ salesChannelId: connections[0].salesChannelId, personaJson: { starterQuestions: [...], avoidTopics: '...' } }).
2026-06-04 — Media library on the public API
Added
- Manage the media library with an API key under
/v1/media. Previously you could attach an already-hosted image URL to a product (updateProductwithimages[]), but there was no way to upload/host/delete the underlying files via the API — only the dashboard. New routes (store-scoped):POST /v1/media— upload a file (multipart/form-data,filepart, ≤10 MB) or ingest a remote image by URL (sourceUrlfield, fetched server-side through the SSRF-safe downloader). Returns the created asset. (media:write)GET /v1/media— paginated media library listing (?page&limit&search). (media:read)GET /v1/media/{id}— fetch a single asset. (media:read)PATCH /v1/media/{id}— update an asset'salt/name. (media:write)DELETE /v1/media/{id}— soft-delete an asset and cascade its key out of every product/variant/category/brand/store/modifier that referenced it. (media:write)
- Two new API-key scopes:
media:readandmedia:write. - SDK methods:
client.uploadMedia(file | { sourceUrl }),client.listMedia(params),client.getMedia(id),client.updateMediaAsset(id, { alt, name }),client.deleteMedia(id).
Typical flow
const asset = await client.uploadMedia(file)→await client.updateProduct(id, { images: [{ url: asset.url, position: 0, isMain: true }] }).
2026-06-04 — Product variant endpoints on the public API
Added
- Variant management is now reachable with an API key under
/v1/products/{id}/variants/*. Previously the SDK shippedcreateVariant,bulkSaveVariants,updateVariant,deleteVariant,getVariantInventory, andupdateVariantInventory, but the matching routes existed only on the internal dashboard surface (/api/products/..., Clerk-only) — so every SDK variant call returnedCannot <METHOD> /api/v1/.... The six routes are now registered on the/v1API-key layer, store-scoped like the rest:POST /v1/products/{id}/variants— create a variant (products:write)POST /v1/products/{id}/variants/bulk— create/update/delete in one transaction (products:write)PATCH /v1/products/{id}/variants/{variantId}— update a variant, incl. its price (products:write)DELETE /v1/products/{id}/variants/{variantId}— delete a variant (products:write)GET /v1/products/{id}/variants/{variantId}/inventory— variant inventory snapshot (inventory:read)PATCH /v1/products/{id}/variants/{variantId}/inventory— adjust variant stock (inventory:write)
- All writes honor the
Idempotency-Keyheader for safe retries.
Why it matters
- For
VARIABLEproducts the real price lives on each variant — the parentbasePriceis read-only (MIN(variants.price)). These routes are what you use to automate variant pricing (e.g. a bulk "raise every price 10%" job) instead of editing it by hand in the dashboard.
2026-05-19 — Public API made discoverable + DTO examples + recipes
Added
- Public API reference page at
/docs/api/endpoints/external-api-v1listing all 136 endpoints under/v1/*. The endpoints themselves have been live since v1.0 — this release surfaces them in the docs site, with auto-generated schemas, request/response examples, and a try-it playground per endpoint. - Quickstart on
/docs/api— three steps from issuing an API key to the first authenticated request. @ApiPropertyannotations with realistic examples on the top 5 DTOs (CreateProductDto,CreateOrderDto,CreateCustomerDto,AddToCartDto,CreateCouponDto). The playground now shows real example values per field instead of"string"placeholders. Long-tail DTOs still pending — see Known limitations.- This Changelog page.
- Cookbook recipes:
Notes
- Tag descriptions on the OpenAPI spec are now full concept paragraphs (not short phrases), so each per-resource page opens with a real intro instead of a one-liner.
- The OpenAPI generator filter was tightened to include the
External API v1tag (previously dropped); the change had no runtime impact, only on docs visibility.
Fixed
WebhookSubscriptionsControllerwas taggedwebhook-subscriptionswhile the public-tag allow-list only includedwebhooks— so all 10 webhook endpoints (subscribe, list, get, update, view deliveries, send test event, rotate-secret, delete, health, event-catalog) were silently filtered out of the spec. The endpoints themselves have been live since v1.0. The cookbook recipe URL inheadless-storefront.mdwas also corrected from/v1/webhooks/subscriptions(wrong) to/stores/{storeId}/webhook-subscriptions(the actual route). (Corrected 2026-08-23: this entry originally listed a "replay" endpoint. There is none — the tenth route isPOST .../{id}/test, which sends a fresh test event. The tag fix also did not survive; those pages still render empty — see Endpoints overview.)
v1.1 roadmap (planned)
Tracked here so integrators can see what's coming. None of these are committed dates — they're the polish backlog.
- API key usage analytics —
GET /stores/{storeId}/api-keys/{id}/usagewith per-day request counts, error rate, last-used IPs. Helps integrators self-diagnose 429s and revoke compromised keys. @ApiPropertyexamples on the long tail of DTOs — currently the top 5 + nested types are annotated. The remaining ~95 DTOs (variants of update/query/filter shapes) still render with auto-derived schema but no example values.
2026-04-30 — v1.0 initial public release
Added
/v1/*surface, 136 endpoints across products, orders, customers, cart, checkout, inventory, sync, addresses, reviews, search, and store info.- API key authentication with required scopes per endpoint (
products:read,orders:write, …). Test keys (brainerce_test_*) for sandbox usage. (Corrected 2026-10-25: a test key is only a different prefix, not a sandbox. It reads and writes the same store data as a live key; see Authentication.) - Tiered rate limits for marketplace-app installations: BRAINERCE first-party = 1000 req/min, ECOMMERCE*PLATFORM connectors = 500 req/min, third-party default = 100 req/min. *(These are installation tiers. API keys are on their own FREE/PRO/GROWTH/ENTERPRISE tiers — see Rate limits.)_
- Outbound webhooks via subscriptions: subscribe to events (
order.created,product.updated,customer.created,payment.succeeded, …), HMAC-signed deliveries, automatic retries, rotate the signing secret. (Corrected 2026-08-23: this entry originally claimed failed deliveries could be replayed. Delivery replay was never built.) Idempotency-Keyheader support, opt-in per route (24-hour dedup window). (Corrected 2026-08-23: originally written as blanket coverage of mutating endpoints; it is per-route — see Idempotency.)- Standard pagination envelope:
{ data: T[], meta: { page, limit, total, totalPages } }, defaultlimit20, hard cap 100. - Standard error shape:
{ statusCode, code, message, error?, details?, timestamp, path }with documented codes at /docs/api/errors.
Authentication modes
| Mode | Use case | Token format |
|---|---|---|
api-key | Server-to-server admin | Authorization: Bearer brainerce_* |
sales-channel | Vibe-coded storefront, public read | salesChannelId in path |
customer-token | On-behalf-of a logged-in customer | Authorization: Bearer ey... |
Known limitations at v1.0
- DTO field examples in the OpenAPI spec are minimal — the playground shows the schema shape but most fields don't have realistic example values yet. Filling these in is on the v1.1 polish list.
- Webhook delivery analytics in the dashboard are read-only — programmatic access to delivery history is on the v1.1 roadmap.
Tooling
- Postman collection — generated from the live OpenAPI spec at
docs/api/postman-collection.json. Import directly into Postman orpnpm --filter frontend generate:postmanto refresh. The collection groups endpoints by resource (External API v1, products, orders, …) matching the docs sidebar.
Format
Each entry uses these headings as applicable:
- Added — new endpoints, fields, query params, error codes, or webhook events
- Changed — non-breaking semantic shifts (e.g. expanded validation that still accepts prior values)
- Deprecated — endpoints/fields entering the 90-day removal window. The entry says when removal happens.
- Removed — finalized removals (always preceded by a Deprecated entry at least 90 days earlier)
- Fixed — bugs corrected
- Security — authentication, authorization, rate-limit, or signature changes
Breaking changes are documented per the Versioning policy — at least 90 days of grace, plus a dashboard banner. There is no deprecation response header; this page and that banner are the two channels.