API ReferenceChangelog

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.

  • statusHistory entries now also carry status (the same as to) and at (the same as timestamp). The storefront starter's status timeline reads status and at, 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}/orders and the storefront's GET /vc/{connectionId}/customers/me/orders) return from, to, timestamp, status and at. 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 by GET /v1/orders and GET /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, scope orders:write, supports Idempotency-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 tag and excludeTag on GET /v1/orders (SDK: getOrders({ tag, excludeTag })). Comma separated; tag keeps orders carrying at least one of the tags, excludeTag hides 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 the search parameter 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 in storeId mode reads the same list, so a shopper typing a product tag name finds the products carrying it; storefronts connected as a vc_* 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 defaultFromLocalPart on GET and PUT /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: info gives [email protected]. The default, and the value you get back when nothing is set, is null, 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 as info). Anything else answers 400. Send null or an empty string to go back to notifications.
  • It does not create a mailbox. The address is send-only, as notifications@ always was: customer replies go to defaultReplyTo, 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, and 0 was stored as 0 by update and as "no sale" by create. To take a product or variant off sale send salePrice: null. A variant with no price of its own is compared with the product basePrice. 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, price or salePrice is 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/products with variants, POST .../variants, duplicating a variant and bulkSaveVariants answer 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 (and translations[locale].description) are sanitised on write, with the same sanitizer a product description uses: <script>, event-handler attributes and javascript: links are removed, ordinary formatting is kept, and plain text such as Tom & Jerry keeps its ampersand (a description with no HTML tag in it is plain text; < and > stay encoded as &lt; and &gt;, and sanitising twice changes nothing). A description that contains tags keeps the sanitizer's output, with & encoded as &amp;. 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): code is trimmed of surrounding whitespace, must not be blank and is at most 100 characters (the same limit the checkout accepts); title is at most 200 and description at most 2000. usageLimit and usageLimitPerCustomer must be whole numbers up to 2147483647 (a fractional value used to answer 500). value, minimumOrderAmount and maximumDiscount are at most 1,000,000,000,000. Each targeting list holds at most 200 ids.
  • Bulk coupon calls (POST /coupons/bulk/update, /coupons/bulk/delete and 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 bulk startsAt or endsAt that is not ISO 8601 answers 400.
  • Customers (POST /v1/customers, PATCH /v1/customers/{id} and the address routes): email is at most 320 characters, phone 50, names 100, tags at 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, code CUSTOMER_ADDRESS_LIMIT).
  • Gift cards: an amount or adjustment above 9999999999.99 answers 400. It used to answer 500.
  • API keys: a tier higher than the store's plan answers 400 API_KEY_TIER_EXCEEDS_PLAN, a rateLimitOverride above the plan ceiling answers 400 API_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.canPurchase now folds in the backorder setting. A TRACKED item on ALLOW or NOTIFY keeps canPurchase: true at zero stock until its backorderLimit is reached (a null limit is no limit). inStock is unchanged and stays false at zero stock, so canPurchase && !inStock is a purchasable backorder. available is real stock and is never negative on a storefront read. On NONE, canPurchase equals inStock as before.
  • The cart, the reservation and the checkout use the same rule. A line on ALLOW or NOTIFY is no longer flagged OUT_OF_STOCK, can be reserved on ON_CART and ON_CHECKOUT channels, 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. A KIT is 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, not available > 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:auth scope): GET .../customers/me/wishlist/keys, GET .../customers/me/wishlist, POST .../customers/me/wishlist, DELETE .../customers/me/wishlist?productId=&variantId= and POST .../customers/me/wishlist/merge. One list per customer, at most 200 items (HTTP 400 code: WISHLIST_FULL when full). Every route answers a JSON body, DELETE included. Writes are throttled (30 per minute, merge 5 per minute). Only ids come back, and only for products that are currently visible: the list, keys and every count leave out a saved product the merchant has unpublished (it stays saved and returns when shown again). Turn the ids into products with ids below. A guest's list lives in the browser and is never sent to these routes until the guest signs in and the storefront calls merge. See Optional features, Wishlist.
  • ids on GET /stores/{storeId}/products and GET /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 empty ids= returns no products, never the whole catalog. Send limit=100 with it. GET /v1/products does 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 /v1 wishlist 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.)

RouteScopeWhat it answers now
POST /v1/syncsync:write501 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:read501 NOT_IMPLEMENTED
POST /v1/coupons/{id}/synccoupons:write200 with success: false and an empty jobIds; nothing is queued. 404 when the coupon does not exist
POST /v1/coupons/{id}/publishcoupons:write200 with an empty jobIds; nothing is queued. The platforms array is still required but ignored
GET /v1/coupons/platform-capabilitiescoupons:read200 with an empty object {}
GET /v1/metafield-conflictsmetafields:read200 with an empty array. No metafield conflict is ever created
POST /v1/metafield-conflicts/{conflictId}/resolve and .../ignoremetafields:write404 for every id
PUT /v1/metafield-definitions/{id}/platformsmetafields:write200. 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.available is 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 (reservationStrategy ON_PAYMENT, the default), createCheckout and createPaymentIntent answer HTTP 400 INSUFFICIENT_STOCK with details.items[] (productId, variantId, available, requested) when a line no longer fits, before a checkout row or a payment exists. Handle it on createPaymentIntent as well as createCheckout. 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 processing answer from POST /payment/sdk-confirm is held, not recorded as failed. Some providers answer processing for minutes while they settle. The route still answers 400 Payment not yet confirmed (status: processing), so the contract and older SDKs are unchanged, but the payment is left untouched: GET /checkout/{id}/payment-status keeps saying pending until the provider's webhook settles it, where it used to flip to failed on the first poll and let the buyer pay a second time. Keep polling; do not call createPaymentIntent again 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. channelPublishes does not carry the status and no /v1 call sets it.
  • Plan order cap. createCheckout answers 402 PLAN_ORDERS_EXHAUSTED (or STORE_SUSPENDED) for a store that has reached its plan's order allowance, before any checkout or payment exists, and createPaymentIntent re-checks it for a checkout opened earlier. See Errors.
  • Storefront bot settings over the API. PUT /v1/storefront-bot/settings (bot-settings:write) accepts, inside personaJson: languages with restrictLanguages (the bot answers only in the listed languages when true, off by default), storeFacts, knowledgePageIds (up to 5), rules (up to 20 { kind: 'always' | 'never' | 'handoff', text }) and memory (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) and dailySpendCeilingUsd. capabilities.cartRecovery is deprecated and ignored. The public memory routes under /storefront-bot/{connectionId}/memory are for the widget, not for a storefront integration.
  • SDK 3.14.1: the brainerce/bot widget follows the shopper's language and the types above (PersonaConfig, BotEscalationConfig). Release notes ship in the package as CHANGELOG.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-missed resends 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}/redeliver with { "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), carry X-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|missed returns counts: { all, succeeded, failed, missed }; each row has state (succeeded, failed, missed, resent, pending) and renderedPayload. Each subscription row has missedCount.
  • headers on 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 give headerNames only. An update merges (null removes 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-* and X-Brainerce-* cannot be set. Dashboard and API only: the admin MCP tools and the AI assistant cannot set or read header values.
  • payloadTemplate on create and update (null clears): 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}/preview with { template?, deliveryId? } renders the body, destination and header names against a stored real event without sending anything (400 when 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}/test now takes confirmLiveCall and answers 400 for an endpoint with headers or a template unless it is true: 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.

  • altTranslations on a media asset (MediaAsset, PATCH /v1/media/{id}): alt text per language, keyed by locale code ({ "he": "...", "fr": "..." }), or null. alt stays the default language, so a key for the store's default language is not stored (it is used as alt when you send none). The map you send replaces the stored one: null or {} clears it and an empty string drops one language. Locale codes are he, pt-BR style, up to 30 languages and 1024 characters each; anything else is a 400.
  • Storefront reads pick the language for you. images[].alt and variant.image.alt are the entry for the requested locale (Accept-Language or ?locale=), falling back to the default-language alt. The shape is still { url, alt }; altTranslations is never sent to a storefront. /v1 reads, which carry no locale, return the raw altTranslations on each image.
  • Alt edits now reach products. PATCH /v1/media/{id} used to change only the asset's own alt. A new alt or altTranslations is 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 alt keeps the stored alt, its source and its translations; sending a different alt marks the image as having its own custom alt.
  • SDK: MediaAsset.altTranslations, UpdateMediaAssetInput.altTranslations, and ProductImage.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) on createPaymentIntent. 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 409 CHECKOUT_TOTAL_CHANGED with previousTotal, newTotal, previousTax, newTax and currency, and nothing is charged. Show the buyer the new total and retry with expectedTotal = newTotal once 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 no region. Nothing is charged. Ask for the state or province, update the shipping address, and call again. The body carries country.
  • 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. createPaymentIntent answers it, and so does completeCheckout for 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 carries country.
  • HTTP 400 CHECKOUT_CART_CHANGED from completeCheckout, 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.
  • appliesToShipping on tax rates (TaxRate, CreateTaxRateDto, UpdateTaxRateDto): false leaves that tax component off the delivery cost (a province where GST applies to postage but PST does not). Absent or true is 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, null on every rate that existed before). A rate is charged only while it is isActive and effectiveFrom <= now < effectiveTo; the end instant itself is not charged. A scheduled or expired rate is ignored by checkout, the estimate and the calculation. Send null on a PATCH to clear a date. effectiveFrom must be earlier than effectiveTo (400 otherwise).
  • Changing a rate from a date. End the old rate first (PATCH its effectiveTo), then POST the new one with the same instant as effectiveFrom. 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 a 409. Rates without dates collide exactly as before.
  • source and sourceVerifiedAt on every rate: where the number came from (manual, preset:<KEY>, import for any API-key write, assistant, mcp; set by the server, not accepted in a request) and when it was last checked (you may send sourceVerifiedAt; a country preset stamps its own date). Informational: calculation never reads them.
  • SDK: TaxRate, CreateTaxRateDto and UpdateTaxRateDto gain the four fields.

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 sessionToken as the cartToken query parameter (or the X-Cart-Session header); 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/extend and /release, POST /checkout and the checkout coupon routes. Reads (GET /cart/{id}, GET /cart/session/{token}) need none, and the /v1 API-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 is 403 CART_ACCESS_DENIED (a 403, never a 401). See Cart ownership proof.
  • Cart 404s now carry a code. CART_NOT_FOUND, CART_ITEM_NOT_FOUND and VARIANT_NOT_FOUND, and cart add answers PRODUCT_NOT_FOUND for 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/recover with { code, attemptId }, trades the code for the cart (with its sessionToken). The code is single use and valid for 10 minutes, the same code and attemptId return the same cart for 120 seconds, every refusal is the same 404, 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_cart yourself.
  • SDK 3.11.0: createCartStore and brainerce/react (CartProvider, useCart, useCartItemCount), BrainerceError.code, { itemId } on smartUpdateCartItem and smartRemoveFromCart, a guest add in one request, cartToken sent automatically, and getSmartCartItemCount() counting units. A storefront on an older SDK sends no cartToken: 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 carry quoted: 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, its price is a placeholder that must not be shown or summed, and its id buys a label without a quote. A real quote omits the field, so existing code that shows price is 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 id identifies the app that issued it. Ids were already opaque and had to be passed back whole to POST /v1/orders/{id}/shipments/app-label; nothing changes if you did that. Checkout rate ids keep their carrier: prefix.
  • SDK: the getOrderShippingRates() return type gains quoted?: 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, default false, and it only applies to customer-input IMAGE and GALLERY fields. 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 /v1 definition responses return it, and setting it to true on any other kind of field is a 400. Every existing field is off, so nothing changes until a merchant turns it on.
  • Send fieldKey with the upload. POST /vc/{connectionId}/customization-upload and POST /stores/{storeId}/customization-upload take an optional fieldKey query parameter: the key of 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-input IMAGE or GALLERY field is not an error; a malformed one is a 400.
  • 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 }) and MetafieldDefinition.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 403 with code: "REVIEWS_DISABLED". Before, the code was the generic FORBIDDEN, 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 on error.details.code; the message is unchanged. getMyProductReview is the exception: it answers 200 with eligible: false and reason: "reviews_disabled". An earlier version of the Product Reviews rules said the 403 carried reason: 'reviews_disabled'; it never did.
  • reviewsEnabled on GET /info. Both /info endpoints (storeId and salesChannelId mode) now return reviewsEnabled: boolean, so a storefront learns the switch without making a failing call. When it is false, render no reviews UI at all, including the star rating on product cards and aggregateRating in structured data: a product keeps its stored avgRating and reviewCount while reviews are off. Absent means an older backend; treat it as on. In storeId mode the value can lag a change by up to five minutes.
  • SDK: StoreInfo.reviewsEnabled (3.9.0). Stores scaffolded with npx create-brainerce-store 1.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_REQUIRED is 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 other 4xx is still stored and replayed.
  • Lock contention on completion and on refunds now returns code: "SERVICE_TEMPORARILY_UNAVAILABLE". The 2026-09-10 entry below promised this code, but these two paths still answered with INTERNAL_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 with 400 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 — active or draft. Omit it to get both.
  • type — SIMPLE, VARIABLE or KIT.

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

  • ProductType gained a third member: KIT, alongside SIMPLE and VARIABLE. 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 own productId, 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 kit price, its per-component allocationMinor split, the pricingMode, discountValue, and available (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 optional pricingMode and discountValue. Refused: a product that is not a KIT, a component from another store, a component that is itself a KIT, a VARIABLE component 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, quantity 1-2000 each. (products:write)
  • POST /v1/products and PATCH /v1/products/{id} accept kitPricingMode and kitDiscountValue. Both are ignored on SIMPLE and VARIABLE products, and omitting kitPricingMode on an update leaves the kit's current mode alone rather than resetting it.

Three pricing modes, and two of them move on their own

kitPricingModeWhat the kit charges
FIXED (default)The kit's own basePrice / salePrice. Unchanged when a component reprices.
SUMExactly what the components cost, recomputed on every read — a component going on sale lowers the kit price by itself.
SUM_MINUS_PERCENTThat 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 inventory object. Read kitAvailable instead: 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 missing inventory as "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 modifier selections (HTTP 400 — a kit is a fixed recipe). An out-of-stock component fails the add with INSUFFICIENT_STOCK naming 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. search matches 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 returns enabled, 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. amount is a decimal string, note is required and permanent, and optional expiresAt (ISO 8601) must be in the future; omit it for a card that does not expire. plaintextCode is 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 original expiresAt carries 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. note required. (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, ACTIVE or DISABLED only. REVOKED is 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 letters I/L/O/U are normalised for you). Returns { tenderId, amountApplied, providerAmountDue }. Supports Idempotency-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. Supports Idempotency-Key. (checkout:write)
  • POST /v1/gift-cards/balance — { code } in, { balance, currency, usable } out. POST, not GET, 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) and providerAmountDue. total is 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. providerAmountDue is total minus every applied card, and equals total when there are none. Existing clients that ignore both fields are unaffected. Render the card as its own line below the total, never folded into discountAmount.
  • POST /v1/checkout/{checkoutId}/complete now succeeds with no captured payment when gift cards cover the whole order (providerAmountDue is "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's images[].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 no approvedAt until someone shows it. (reviews:write)

Changed

  • GET /v1/products/{productId}/reviews now returns images on every review — every photo including ones hidden or still awaiting approval, since this is the moderation view. Each carries id, url, thumbnailUrl, width, height, approvedAt (null = waiting on the merchant) and hiddenAt. Existing clients that ignore the field are unaffected.
  • POST /v1/products/{productId}/reviews accepts an optional imageKeys array (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 a jobId back immediately (202 Accepted). Every field POST /v1/products accepts 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 of QUEUED / RUNNING / COMPLETED / COMPLETED_WITH_ERRORS / FAILED / CANCELLED. skipped rows are neither failures nor creations. COMPLETED_WITH_ERRORS means 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 an importId. This is how a 3,000–50,000 product catalog is sent: chunk it into several requests carrying the same importId, then poll this once instead of polling each jobId. The aggregate status is the least-complete chunk, and finishedAt stays null until all of them finish. (products:read)
  • GET /v1/products/bulk/{jobId}/errors — paginated, one entry per failed row, carrying the 1-indexed row from your submitted array plus sku, externalId, productName, a code (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-Key header or an idempotencyKey field in the body returns the ORIGINAL jobId on a re-send. The body field is stored in the database rather than Redis, so it outlives the header's 24-hour window — and the bulk_create_products MCP tool has no headers to carry one.
  • Row-level: a row whose sku or externalId already 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 a sku nor an externalId has nothing to match on and will be duplicated by a re-send outside the key window — set externalId on 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() and setBillingAddress() now drop lat, lng and formattedAddress from the request body instead of forwarding them. Spreading the address you got from getAddressDetails() 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 with 400 "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-address and .../billing-address still reject every property outside their documented payload, including lat/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. placeId is how exact coordinates reach zone matching; address.lat/address.lng are 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 (priority was only an ORDER BY). The winner is decided by, in order: lowest priority; 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.
  • availableShippingRates on 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-address accepts placeId and placeSessionToken. placeId is the id of the autocomplete suggestion the shopper picked (from GET /vc/{connectionId}/checkout/address-suggestions; the /v1 surface has no autocomplete route); the server re-resolves it to that address's exact coordinates and matches map-drawn ("polygon") shipping zones against them. placeSessionToken is the token used for those autocomplete calls and is optional — the resolved place is cached server-side for 24h by placeId, 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 placeId the 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, send placeId. Stores whose zones are country/region/postal-code based are unaffected.

Not added, deliberately

  • There is no lat/lng field 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 a placeId — never accepted from the caller. (Sending them anyway is a hard 400; 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 returned id is 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 with trackingNumber, trackingUrl, labelUrl, labelFormat, rate/rateCurrency, and its carrier tracking events (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-label accepts labelFormat (PDF | PNG | ZPL | EPL, default PDF) and customsContentsType (merchandise | gift | documents | sample | return, cross-border only). The response gained labelFormat — carriers that cannot produce the requested format return their closest match rather than failing the purchase.
  • OrderSummaryDto — returned verbatim by GET /v1/orders and GET /v1/orders/{id} — gained shippingSelection: { carrier, service, methodName, amount }, the live carrier service the shopper chose and paid for, or null for flat-rate/zone shipping, pickup, and imported orders. Match it against a fresh app-rates quote on carrier + 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, and carrier. They were declared on the SDK's Order type but never populated on this route.
  • SDK methods: client.getOrderShippingRates(orderId), client.getOrderShipments(orderId).

Changed

  • availableShippingRates on 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 carries speedTier (cheapest | balanced | fastest), derived per quote rather than mapped from service names; render that and estimatedDays rather than name, 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 (updateProduct with images[]), 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, file part, ≤10 MB) or ingest a remote image by URL (sourceUrl field, 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's alt / 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:read and media: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 shipped createVariant, bulkSaveVariants, updateVariant, deleteVariant, getVariantInventory, and updateVariantInventory, but the matching routes existed only on the internal dashboard surface (/api/products/..., Clerk-only) — so every SDK variant call returned Cannot <METHOD> /api/v1/.... The six routes are now registered on the /v1 API-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-Key header for safe retries.

Why it matters

  • For VARIABLE products the real price lives on each variant — the parent basePrice is 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-v1 listing 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.
  • @ApiProperty annotations 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 v1 tag (previously dropped); the change had no runtime impact, only on docs visibility.

Fixed

  • WebhookSubscriptionsController was tagged webhook-subscriptions while the public-tag allow-list only included webhooks — 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 in headless-storefront.md was 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 is POST .../{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}/usage with per-day request counts, error rate, last-used IPs. Helps integrators self-diagnose 429s and revoke compromised keys.
  • @ApiProperty examples 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-Key header 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 } }, default limit 20, hard cap 100.
  • Standard error shape: { statusCode, code, message, error?, details?, timestamp, path } with documented codes at /docs/api/errors.

Authentication modes

ModeUse caseToken format
api-keyServer-to-server adminAuthorization: Bearer brainerce_*
sales-channelVibe-coded storefront, public readsalesChannelId in path
customer-tokenOn-behalf-of a logged-in customerAuthorization: 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 or pnpm --filter frontend generate:postman to 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.

On this page

2026-10-29 — Order status history: what the shopper-facing statusHistory carries2026-10-28 — Order tags: internal labels on an order2026-10-28 — Email settings: choose the part before the @ of the sending address2026-10-27 — One sale price rule, a 500-variant cap per product, and sanitised category and brand descriptions2026-10-27 — Field limits on coupons, customers, gift cards and API keys: calls that used to answer 500 or save junk now answer 4002026-10-27 — Backorders (stock policy Allow / Notify) are honoured by the cart, the reservation, the checkout and the order2026-10-26 — Wishlist routes and an ids filter on the public catalog read2026-10-25 — Shopify and WooCommerce sync and publish are retired: seven /v1 routes now answer as no-ops2026-10-25 — Per-channel stock at checkout, a pending payment is no longer failed, per-channel product status, and bot settings2026-10-22 — Outbound webhooks: missed events and resend, custom headers, body template2026-10-20 — Image alt text per language2026-10-19 — Checkout tax is re-verified before a charge, and three new payment errors2026-10-16 — Tax rates: effective dates, source and 409 on overlapping dates2026-10-11 — Cart ownership proof (cartToken), cart error codes, and a one-time code in the recovery link2026-10-11 — Order shipping rates: quoted, and rate ids that name their shipping app2026-10-09 — Customization uploads: a per-field print-quality setting, fieldKey, and a 10 MB ceiling2026-10-07 — Reviews switched off: a REVIEWS_DISABLED error code, and reviewsEnabled on /info2026-10-05 — payment.failed is no longer sent for a checkout that is already paid2026-10-05 — Checkout completion retries: PAYMENT_REQUIRED is no longer replayed; lock contention keeps its code2026-10-05 — GET /v1/products now honours status and type; /v1 auth header clarified2026-09-10 — Adding to a storefront cart can now return 400 CART_LINE_LIMIT_REACHED2026-09-10 — A transient internal failure no longer reports itself as a payment-provider problem2026-09-10 — Checkout completion's fail-closed 503 gets its own code2026-09-07 — PATCH /vc/{connectionId}/customers/me returns the profile, not the whole record2026-09-07 — Three responses that disagreed with their own documentation2026-09-07 — compatible-providers no longer returns installation secrets2026-09-06 — The KIT product type, and how a kit is priced2026-09-05 — Gift cards over the admin API2026-09-03 — Gift cards at checkout2026-08-27 — Photos on product reviews2026-08-22 — Bulk product creation2026-08-11 — Spreading a resolved address no longer 400s the whole checkout2026-08-10 — One zone per address, and exact coordinates for map-drawn zones2026-08-09 — Order shipments, live carrier rates, and the rate the shopper paid for2026-07-15 — Storefront Bot settings and conversations on the public API2026-06-04 — Media library on the public API2026-06-04 — Product variant endpoints on the public API2026-05-19 — Public API made discoverable + DTO examples + recipesv1.1 roadmap (planned)2026-04-30 — v1.0 initial public releaseFormat