Use your own cart API
By default, the SDK reads and writes the cart directly through the Shopify Storefront API. No adapter is needed. Implement XcottonCartAdapter only when cart operations must pass through your own API or BFF (for example, to attach server-side credentials, enforce custom validation, or run side effects on cart writes), and pass it to init.
An adapter has five methods that mirror the Storefront Cart API:
| Method | Storefront query / mutation | Return value |
|---|---|---|
getCart(cartId) | cart | Cart or null |
addLines(cartId, lines) | cartLinesAdd | The updated Cart |
updateLines(cartId, lines) | cartLinesUpdate | The updated Cart |
removeLines(cartId, lineIds) | cartLinesRemove | The updated Cart |
updateAttributes(cartId, attributes) | cartAttributesUpdate | The updated Cart |
The SDK automatically passes cartId from the currently bound cart, so your adapter never needs to track the active cart ID on its own.
Before you start
Section titled “Before you start”- The store is running SDK v0.3. If you are migrating a v0.2 adapter, read Upgrade from v0.2 to v0.3 first.
- Your backend endpoints can query the cart, add/update/remove lines, and replace cart-level
attributes. - Write endpoints return the complete
Cartstructure expected by the SDK.getCartmay also returnnull(see Cart). Type-check your adapter againstXcottonCartAdapterfrom xcotton-types.ts.
- Implement the five adapter methods, forwarding the SDK’s arguments to your backend and returning the full updated
Cart. - Register the adapter once per page load via
init({ adapter, onCartChange }). You can initialize before a cart exists, since PP product page pricing does not require a cart. - Call
syncCart(cart.id)after every successful storefront cart write, including the first bind and cart switches. Await completion. The SDK decides whether this is a first bind, a cart switch, or a same-cart refresh. - Inside
onCartChange, only refresh your UI. Never callsyncCartfromonCartChange, and never re-emit SDK writes as storefront cart events (see Keep the cart in sync).
You can override only some methods
Section titled “You can override only some methods”Any method you omit falls back to the SDK’s built-in Storefront API implementation. When mixing custom and default methods:
- All methods must operate on the same Storefront cart, use the same line IDs, and hold valid credentials.
- If your BFF rewrites or synthesizes custom line IDs rather than returning native Storefront
CartLine.idvalues, the built-in methods won’t recognize them. You must implement every line-based method yourself. - If a custom method throws or rejects, the SDK will not fall back to the built-in Storefront method.
Input rules for writes
Section titled “Input rules for writes”| Operation | Rule |
|---|---|
| Add a line | Pass merchandiseId as a full ProductVariant GID.quantity is optional and defaults to 1 |
| Update a line | Address the line by its current CartLine.id. Supply quantity, attributes, or both. quantity: 0 removes the line: the SDK passes it to updateLines unchanged, so your implementation must treat it as a deletion |
| Remove a line | Prefer removeLines |
| Locating lines | Never look up a line by variant ID. One variant can span multiple lines, and attribute updates may change line IDs. Never turn a failed update into an add |
| Line attributes | Omitting or passing null preserves existing attributes.[] clears them. A non-empty array replaces the entire list |
| Cart attributes | updateAttributes always receives the complete attribute list ([] clears all attributes). The SDK pre-merges its own keys, so write the array as-is without re-reading and merging |
| Attribute values | Values must be strings. Duplicate keys and invalid quantities are rejected by the SDK before calling your adapter |
Return value rules
Section titled “Return value rules”| Scenario | Contract requirement |
|---|---|
getCart cannot find the cart | Return null only when you have confirmed the cart does not exist. Return an empty cart as a complete Cart with lines.nodes: [], not null. The SDK rejects operations that need a real cart with CART_NOT_FOUND |
| Network error, auth failure, rate limit | Reject the Promise. Never return null on transient errors, or the SDK will treat the cart as deleted |
| Write succeeded | Return the complete post-mutation Cart. Returning null or undefined violates the contract: the SDK will re-read the cart to recover state, but still rejects the call with ADAPTER_RESPONSE_INVALID |
| Write failed | Reject the Promise. Because a failed write may have partially applied on the server, disable automatic retries in your HTTP client. The SDK will not replay or roll back failed writes |
| API throttled | Reject an Error with at least status: 429 or code: 'THROTTLED' (a message containing 429 / THROTTLED is also recognized). With retryAfterMs, the SDK uses that cooldown. Otherwise it applies the default 30-second cooldown |
The SDK validates every returned Cart. If a read result fails validation, the SDK re-reads once through the adapter and recovers when that read passes. If a write result fails validation, the SDK rejects the operation:
cart.idmust match the currently bound cart ID.- The cart total currency (
cost.totalAmount.currencyCode) must match thewindow.xcottonConfig.currencyconfigured at initialization. The built-in and custom adapters follow the same rule, and a mismatch rejects before quoting or writing. - All cart line pages must be fetched (
lines.pageInfo.hasNextPagemust befalse), with no duplicate line IDs. - Monetary fields must preserve Storefront
MoneyV2formatting:amountis a decimal string, and everycurrencyCodemust match the cart total’s currency. Do not multiply amounts by 100 or substitute non-zero prices for zero-cost lines. - Preserve every required field in the Cart contract. Pricing also reads shipping eligibility (
requiresShipping), gift card flag (isGiftCard), SKU, title,vendor,productType,discountCodes, and linecost. Product identity (product.id,product.handle), linequantity, andattributesare required too. Legacy v0.2 Ajax Cart fields are not needed.
Write outcomes are reported as four states: not sent, explicitly rejected without effect, partially or fully confirmed, and unknown. The SDK puts the state into context.writeOutcome when it can confirm it. If a method returns null / undefined, or a post-write pagination read fails, the SDK performs a single read-only recovery read and still rejects the operation. It never replays the mutation.
Custom adapters own pagination and must return a complete Cart with lines.pageInfo.hasNextPage === false. The SDK validates only that final snapshot. Cross-checking cart-level facts (total, attributes, discount codes) across pages is a capability of the default adapter while it fetches pages, and the SDK never reads the intermediate pages of a custom adapter. When a read snapshot is incomplete, pagination is invalid, or line IDs are duplicated, the SDK re-reads once through the adapter. This lowers the mixed-snapshot risk but is not a cross-page transaction. Synchronization and writes on the same cart are coordinated by the SDK: starting another cart write while one is in flight rejects with OPERATION_IN_PROGRESS, so your adapter must not rely on its own queue to serialize them.
Full example
Section titled “Full example”In the example below, myApi represents your storefront’s cart client. The example imports it from lib/cart.ts, so adjust that path for your project. The second file replaces lib/xcotton.ts from the Quick start. The change imports cartAdapter and passes adapter: cartAdapter to init.
import type { XcottonCartAdapter } from './xcotton-types'import { myApi } from './cart'// The SDK passes the bound cartId; every write returns the full Cart.export const cartAdapter: XcottonCartAdapter = { getCart: (cartId) => myApi.getCart(cartId), addLines: (cartId, lines) => myApi.addLines(cartId, lines), updateLines: (cartId, lines) => myApi.updateLines(cartId, lines), removeLines: (cartId, lineIds) => myApi.removeLines(cartId, lineIds), updateAttributes: (cartId, attributes) => myApi.updateAttributes(cartId, attributes),}import type { XcottonApi, XcottonCartAdapter } from './xcotton-types'import { cartAdapter } from './cart-adapter'import { reloadCartView } from './cart'
const cartChangeListeners = new Set<() => void>()
export function onXcottonCartChange(listener: () => void): () => void { cartChangeListeners.add(listener) return () => { cartChangeListeners.delete(listener) }}
// Initialize once per page: every component shares one Promise and callback reference.const handleCartChange = () => { void reloadCartView().catch(console.error) // Confirmed cart facts can accompany a business-sync failure; components must read offers and handle errors. for (const listener of cartChangeListeners) listener()}
let initPromise: Promise<void> | undefined
export function xcottonInit(): Promise<void> { const sdk = window.xcotton if (!sdk) return Promise.reject(new Error('[XC] sdk.js has not loaded yet')) if (initPromise) return initPromise // One-shot: repeating this call does not reinitialize; bind a late cartId with syncXcotton(cartId). initPromise = sdk.init({ adapter: cartAdapter, onCartChange: handleCartChange, }) return initPromise}
// Hand storefront writes to the SDK: it decides first bind, cart switch, and same-cart refresh.// Initialize the session first; the storefront keeps no binding state or queue.export async function syncXcotton(cartId: string): Promise<void> { const sdk: XcottonApi | undefined = window.xcotton if (!sdk) throw new Error('[XC] sdk.js has not loaded yet') await xcottonInit() await sdk.syncCart(cartId)}import type { XcottonCartAdapter } from './xcotton-types'import { myApi } from './cart'// The SDK passes the bound cartId; every write returns the full Cart.export const cartAdapter: XcottonCartAdapter = { getCart: (cartId) => myApi.getCart(cartId), addLines: (cartId, lines) => myApi.addLines(cartId, lines), updateLines: (cartId, lines) => myApi.updateLines(cartId, lines), removeLines: (cartId, lineIds) => myApi.removeLines(cartId, lineIds), updateAttributes: (cartId, attributes) => myApi.updateAttributes(cartId, attributes),}import type { XcottonApi, XcottonCartAdapter } from './xcotton-types'import { cartAdapter } from './cart-adapter'import { reloadCartView } from './cart'
const cartChangeListeners = new Set<() => void>()
export function onXcottonCartChange(listener: () => void): () => void { cartChangeListeners.add(listener) return () => { cartChangeListeners.delete(listener) }}
// Initialize once per page: every component shares one Promise and callback reference.const handleCartChange = () => { void reloadCartView().catch(console.error) // Confirmed cart facts can accompany a business-sync failure; components must read offers and handle errors. for (const listener of cartChangeListeners) listener()}
let initPromise: Promise<void> | undefined
export function xcottonInit(): Promise<void> { const sdk = window.xcotton if (!sdk) return Promise.reject(new Error('[XC] sdk.js has not loaded yet')) if (initPromise) return initPromise // One-shot: repeating this call does not reinitialize; bind a late cartId with syncXcotton(cartId). initPromise = sdk.init({ adapter: cartAdapter, onCartChange: handleCartChange, }) return initPromise}
// Hand storefront writes to the SDK: it decides first bind, cart switch, and same-cart refresh.// Initialize the session first; the storefront keeps no binding state or queue.export async function syncXcotton(cartId: string): Promise<void> { const sdk: XcottonApi | undefined = window.xcotton if (!sdk) throw new Error('[XC] sdk.js has not loaded yet') await xcottonInit() await sdk.syncCart(cartId)}import type { XcottonCartAdapter } from './xcotton-types'import { myApi } from './cart'// The SDK passes the bound cartId; every write returns the full Cart.export const cartAdapter: XcottonCartAdapter = { getCart: (cartId) => myApi.getCart(cartId), addLines: (cartId, lines) => myApi.addLines(cartId, lines), updateLines: (cartId, lines) => myApi.updateLines(cartId, lines), removeLines: (cartId, lineIds) => myApi.removeLines(cartId, lineIds), updateAttributes: (cartId, attributes) => myApi.updateAttributes(cartId, attributes),}import type { XcottonApi, XcottonCartAdapter } from './xcotton-types'import { cartAdapter } from './cart-adapter'import { reloadCartView } from './cart'
const cartChangeListeners = new Set<() => void>()
export function onXcottonCartChange(listener: () => void): () => void { cartChangeListeners.add(listener) return () => { cartChangeListeners.delete(listener) }}
// Initialize once per page: every component shares one Promise and callback reference.const handleCartChange = () => { void reloadCartView().catch(console.error) // Confirmed cart facts can accompany a business-sync failure; components must read offers and handle errors. for (const listener of cartChangeListeners) listener()}
let initPromise: Promise<void> | undefined
export function xcottonInit(): Promise<void> { const sdk = window.xcotton if (!sdk) return Promise.reject(new Error('[XC] sdk.js has not loaded yet')) if (initPromise) return initPromise // One-shot: repeating this call does not reinitialize; bind a late cartId with syncXcotton(cartId). initPromise = sdk.init({ adapter: cartAdapter, onCartChange: handleCartChange, }) return initPromise}
// Hand storefront writes to the SDK: it decides first bind, cart switch, and same-cart refresh.// Initialize the session first; the storefront keeps no binding state or queue.export async function syncXcotton(cartId: string): Promise<void> { const sdk: XcottonApi | undefined = window.xcotton if (!sdk) throw new Error('[XC] sdk.js has not loaded yet') await xcottonInit() await sdk.syncCart(cartId)}import type { XcottonCartAdapter } from './xcotton-types'import { myApi } from './cart'// The SDK passes the bound cartId; every write returns the full Cart.export const cartAdapter: XcottonCartAdapter = { getCart: (cartId) => myApi.getCart(cartId), addLines: (cartId, lines) => myApi.addLines(cartId, lines), updateLines: (cartId, lines) => myApi.updateLines(cartId, lines), removeLines: (cartId, lineIds) => myApi.removeLines(cartId, lineIds), updateAttributes: (cartId, attributes) => myApi.updateAttributes(cartId, attributes),}import type { XcottonApi, XcottonCartAdapter } from './xcotton-types'import { cartAdapter } from './cart-adapter'import { reloadCartView } from './cart'
const cartChangeListeners = new Set<() => void>()
export function onXcottonCartChange(listener: () => void): () => void { cartChangeListeners.add(listener) return () => { cartChangeListeners.delete(listener) }}
// Initialize once per page: every component shares one Promise and callback reference.const handleCartChange = () => { void reloadCartView().catch(console.error) // Confirmed cart facts can accompany a business-sync failure; components must read offers and handle errors. for (const listener of cartChangeListeners) listener()}
let initPromise: Promise<void> | undefined
export function xcottonInit(): Promise<void> { const sdk = window.xcotton if (!sdk) return Promise.reject(new Error('[XC] sdk.js has not loaded yet')) if (initPromise) return initPromise // One-shot: repeating this call does not reinitialize; bind a late cartId with syncXcotton(cartId). initPromise = sdk.init({ adapter: cartAdapter, onCartChange: handleCartChange, }) return initPromise}
// Hand storefront writes to the SDK: it decides first bind, cart switch, and same-cart refresh.// Initialize the session first; the storefront keeps no binding state or queue.export async function syncXcotton(cartId: string): Promise<void> { const sdk: XcottonApi | undefined = window.xcotton if (!sdk) throw new Error('[XC] sdk.js has not loaded yet') await xcottonInit() await sdk.syncCart(cartId)}import type { XcottonCartAdapter } from './xcotton-types'import { myApi } from './cart'// The SDK passes the bound cartId; every write returns the full Cart.export const cartAdapter: XcottonCartAdapter = { getCart: (cartId) => myApi.getCart(cartId), addLines: (cartId, lines) => myApi.addLines(cartId, lines), updateLines: (cartId, lines) => myApi.updateLines(cartId, lines), removeLines: (cartId, lineIds) => myApi.removeLines(cartId, lineIds), updateAttributes: (cartId, attributes) => myApi.updateAttributes(cartId, attributes),}import type { XcottonApi, XcottonCartAdapter } from './xcotton-types'import { cartAdapter } from './cart-adapter'import { reloadCartView } from './cart'
const cartChangeListeners = new Set<() => void>()
export function onXcottonCartChange(listener: () => void): () => void { cartChangeListeners.add(listener) return () => { cartChangeListeners.delete(listener) }}
// Initialize once per page: every component shares one Promise and callback reference.const handleCartChange = () => { void reloadCartView().catch(console.error) // Confirmed cart facts can accompany a business-sync failure; components must read offers and handle errors. for (const listener of cartChangeListeners) listener()}
let initPromise: Promise<void> | undefined
export function xcottonInit(): Promise<void> { const sdk = window.xcotton if (!sdk) return Promise.reject(new Error('[XC] sdk.js has not loaded yet')) if (initPromise) return initPromise // One-shot: repeating this call does not reinitialize; bind a late cartId with syncXcotton(cartId). initPromise = sdk.init({ adapter: cartAdapter, onCartChange: handleCartChange, }) return initPromise}
// Hand storefront writes to the SDK: it decides first bind, cart switch, and same-cart refresh.// Initialize the session first; the storefront keeps no binding state or queue.export async function syncXcotton(cartId: string): Promise<void> { const sdk: XcottonApi | undefined = window.xcotton if (!sdk) throw new Error('[XC] sdk.js has not loaded yet') await xcottonInit() await sdk.syncCart(cartId)}import type { XcottonCartAdapter } from './xcotton-types'import { myApi } from './cart'// The SDK passes the bound cartId; every write returns the full Cart.export const cartAdapter: XcottonCartAdapter = { getCart: (cartId) => myApi.getCart(cartId), addLines: (cartId, lines) => myApi.addLines(cartId, lines), updateLines: (cartId, lines) => myApi.updateLines(cartId, lines), removeLines: (cartId, lineIds) => myApi.removeLines(cartId, lineIds), updateAttributes: (cartId, attributes) => myApi.updateAttributes(cartId, attributes),}import type { XcottonApi, XcottonCartAdapter } from './xcotton-types'import { cartAdapter } from './cart-adapter'import { reloadCartView } from './cart'
const cartChangeListeners = new Set<() => void>()
export function onXcottonCartChange(listener: () => void): () => void { cartChangeListeners.add(listener) return () => { cartChangeListeners.delete(listener) }}
// Initialize once per page: every component shares one Promise and callback reference.const handleCartChange = () => { void reloadCartView().catch(console.error) // Confirmed cart facts can accompany a business-sync failure; components must read offers and handle errors. for (const listener of cartChangeListeners) listener()}
let initPromise: Promise<void> | undefined
export function xcottonInit(): Promise<void> { const sdk = window.xcotton if (!sdk) return Promise.reject(new Error('[XC] sdk.js has not loaded yet')) if (initPromise) return initPromise // One-shot: repeating this call does not reinitialize; bind a late cartId with syncXcotton(cartId). initPromise = sdk.init({ adapter: cartAdapter, onCartChange: handleCartChange, }) return initPromise}
// Hand storefront writes to the SDK: it decides first bind, cart switch, and same-cart refresh.// Initialize the session first; the storefront keeps no binding state or queue.export async function syncXcotton(cartId: string): Promise<void> { const sdk: XcottonApi | undefined = window.xcotton if (!sdk) throw new Error('[XC] sdk.js has not loaded yet') await xcottonInit() await sdk.syncCart(cartId)}import type { XcottonCartAdapter } from './xcotton-types'import { myApi } from './cart'// The SDK passes the bound cartId; every write returns the full Cart.export const cartAdapter: XcottonCartAdapter = { getCart: (cartId) => myApi.getCart(cartId), addLines: (cartId, lines) => myApi.addLines(cartId, lines), updateLines: (cartId, lines) => myApi.updateLines(cartId, lines), removeLines: (cartId, lineIds) => myApi.removeLines(cartId, lineIds), updateAttributes: (cartId, attributes) => myApi.updateAttributes(cartId, attributes),}import type { XcottonApi, XcottonCartAdapter } from './xcotton-types'import { cartAdapter } from './cart-adapter'import { reloadCartView } from './cart'
const cartChangeListeners = new Set<() => void>()
export function onXcottonCartChange(listener: () => void): () => void { cartChangeListeners.add(listener) return () => { cartChangeListeners.delete(listener) }}
// Initialize once per page: every component shares one Promise and callback reference.const handleCartChange = () => { void reloadCartView().catch(console.error) // Confirmed cart facts can accompany a business-sync failure; components must read offers and handle errors. for (const listener of cartChangeListeners) listener()}
let initPromise: Promise<void> | undefined
export function xcottonInit(): Promise<void> { const sdk = window.xcotton if (!sdk) return Promise.reject(new Error('[XC] sdk.js has not loaded yet')) if (initPromise) return initPromise // One-shot: repeating this call does not reinitialize; bind a late cartId with syncXcotton(cartId). initPromise = sdk.init({ adapter: cartAdapter, onCartChange: handleCartChange, }) return initPromise}
// Hand storefront writes to the SDK: it decides first bind, cart switch, and same-cart refresh.// Initialize the session first; the storefront keeps no binding state or queue.export async function syncXcotton(cartId: string): Promise<void> { const sdk: XcottonApi | undefined = window.xcotton if (!sdk) throw new Error('[XC] sdk.js has not loaded yet') await xcottonInit() await sdk.syncCart(cartId)}Verify and troubleshoot
Section titled “Verify and troubleshoot”| Action | Expected result |
|---|---|
| Add the same variant as two separate lines, then update/remove each | Each operation affects only the targeted line |
| Test a cart with more lines than a single GraphQL page | lines.nodes contains all lines and hasNextPage is false |
| Change a main product line’s quantity | The linked protection line’s quantity updates to match |
Remove protection for one main line (removeProtection) | The main line stays, its _PlanID is cleared while other attributes remain, and other lines in the shared group keep their protection |
Empty the cart in cart mode, then sync | No leftover fee line remains |
| Simulate a dropped write response | No duplicate items are added, failure isn’t shown as success, and the user can continue after a re-read |
| Start another cart write while an SDK sync or write is in flight | The later request rejects with OPERATION_IN_PROGRESS and the adapter receives a single mutation |
| Change cart attributes or the total while the default adapter pages through a cart | The SDK discards the mixed result and re-reads a bounded number of times without replaying writes. A custom adapter must keep its own pages consistent |
| Trigger an SDK cart write | Both the cart page and drawer update together, and callback errors are caught |
| Error message | Cause |
|---|---|
[XC] line.id: duplicate or missing cart line | A line ID targeted for update/removal is missing from the returned cart, or appears twice in one request |
[XC] updateLines: quantity or attributes required | A line update omitted both quantity and attributes |
Contains response does not match bound cart | The returned cart.id does not match the currently bound cart ID |
Contains inconsistent currency | A monetary field’s currencyCode differs from the cart total’s currency |
Contains cart currency does not match the configured market | The cart total currency differs from window.xcottonConfig.currency, so the currency guard rejected it |
[XC] Cart requests are rate limited | The adapter returned a throttling signal (429 / THROTTLED). The SDK entered its cooldown, or this request is being rejected during that cooldown |
Contains duplicate line or duplicate attribute | A line ID or attribute key is duplicated in the returned payload |
For complete type signatures, see the SDK Reference.