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-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 (fromPOST /v1/checkout/address-autocomplete); 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. Requires a PRO+ 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. - 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.