Keep the cart in sync
Your storefront and the SDK read and write the same Shopify cart: your storefront adds main products and updates quantities, while the SDK writes fee lines and protection lines. To keep both sides consistent without circular updates, communication flows through two dedicated, one-way channels.
This guide applies to every integration mode, as well as stores that only use Product Protection (PP).
Two directions
Section titled “Two directions”| Who modified the cart | Direction | What your code does |
|---|---|---|
| Your storefront: add, update quantity, remove, clear, switch cart | Storefront → SDK | After the write succeeds, await syncCart(cart.id) |
The SDK: an SP switch toggle in cart mode, pp.addToCart(), pp.addProtection(), etc. | SDK → storefront | The SDK invokes onCartChange(cart). You refresh your UI |
One rule governs the entire flow: call syncCart(cart.id) after your storefront writes the cart, and only refresh your UI inside onCartChange. The first bind, cart switches, and same-cart refreshes use the same method. The SDK decides which case applies.
After your storefront writes the cart
Section titled “After your storefront writes the cart”Whenever a storefront cart mutation succeeds, call syncCart(cart.id) and await it. Keep shared cart and drawer controls busy through both the storefront write and SDK synchronization. Disable further quantity changes, removal, clearing, and checkout until synchronization finishes so the two sides cannot overlap writes:
await addMainProduct()await window.xcotton!.syncCart(cart.id)syncCart synchronizes only the modules affected by the current cart facts and returns an independent Cart when it completes:
- Re-confirms the cart facts (a complete
Cartargument is validated and adopted as the snapshot). - For a same-cart refresh with a previously confirmed snapshot, recalculates the SP quote and reconciles the fee line only when SP input changes: non-protection lines, cart attributes, or discount codes. A first bind or cart switch reruns the first-sync reconciliation instead. In
cartmode the fee line is removed when the cart is empty or the subtotal falls outside the eligible range. Incheckoutandcheckout+modes fee line changes are deferred until checkout. Adding or removing PP protection lines is not SP input and does not trigger a full SP re-quote. - Refreshes the PP cart quotes returned by
getCartQuotes()when lines, cart attributes, or discounts change.getRelations()reads the current cart snapshot directly and does not depend on this projection. - Invokes
onCartChangeonce and returns the confirmed independentCartsnapshot. The return value reflects this sync only. Later external operations may make it stale.
syncCart accepts three inputs: no argument (the currently bound cart), a cartId string, or a complete Cart the storefront already received from the server. Calling it without an argument while no cart is bound rejects with an error starting [XC] syncCart requires a cartId before the first synchronization. A complete Cart must contain every required field, belong to the same cart, and use the configured currency. Otherwise the call rejects, the input never becomes a valid snapshot, and the previously known snapshot is invalidated as well.
Synchronization also checks the cart currency: cart.cost.totalAmount.currencyCode must match window.xcottonConfig.currency, otherwise the call rejects. When switching markets, prepare a cart for the new market and declare the new country / currency before loading a new page. Runtime configuration changes do not update the current SDK session.
Initialization and recovery
Section titled “Initialization and recovery”init is a one-shot entry point for the page lifetime. Call it once at startup to register adapter and onCartChange. Every refresh, first bind, and cart switch goes through syncCart afterwards. Calling init while it is in progress, after it succeeded, or after it failed rejects explicitly. It never shares the in-flight Promise as a success and cannot replace the configuration registered by the first call.
- Environment failure (configuration or assembly failed, including PP being enabled but failing to initialize):
initrejects withSDK_INIT_FAILED, the SDK keeps no half-initialized surface, and bothsyncCartand cart-dependent operations report that it is not initialized. Fix the cause and reload the page. - Environment ready, first sync failed:
init({ cartId })rejects withSDK_INIT_SYNC_FAILED, carrying thecartId, failure stage, and the write outcome when it can confirm one. Product-level capabilities such as PP quoting remain available. Recover withsyncCart(cartId)from the real cart state without replaying previous writes, and do not callinitagain. - Environment ready, first sync held by an in-flight operation:
init({ cartId })rejects withOPERATION_IN_PROGRESS(operation=init) and the lifecycle stays busy. A repeatedinitthen rejects withSDK_ALREADY_INITIALIZED(phase=busy). Wait for the active operation to settle, then recover withsyncCart(cartId). Do not callinitagain. - After a failed first sync, repeated
initcalls keep rejecting (phase=sync_failed, orphase=busywhen an in-flight operation held the first sync). A successful recovery does not makeinitcallable again.
Syncing and switching carts
Section titled “Syncing and switching carts”syncCart is the only entry point for cart identity (no argument, cartId, or a complete Cart). Call it in these situations:
- First sync: when the page first obtains a cart ID. Passing
cartIddirectly toinit({ cartId })has the same success path. On failure,initrejects withSDK_INIT_SYNC_FAILED(orOPERATION_IN_PROGRESSwhen an in-flight operation holds the first sync) whilesyncCart(cartId)rejects only that sync. - Switching carts: when the customer transitions to a different cart (for example, after logging in and merging carts), pass the new cart ID. The SDK reconciles the fee line on the new cart using the first-sync rules.
- Refreshing the same cart: passing the ID that is already bound re-reads and synchronizes that cart without repeating the first-sync reconciliation.
Your storefront does not record “the last bound ID” and does not queue calls: the Quick start helper syncXcotton(cartId) waits for initialization and then calls syncCart. The startup component observes the cartId prop and calls syncXcotton(cartId) when identity is restored or changes. Rendering the same ID again adds no requests. Identity observation is independent of storefront write events, so restoring a cart through a query also binds it.
Public syncCart calls run in order. Adjacent concurrent calls with the same input merge into one sync and share its result. When a call overlaps an internal cart write that does not go through the public surface, such as a panel switch write, it rejects with OPERATION_IN_PROGRESS. The context carries activeOperation and requestedOperation, and recovery.action is WAIT_FOR_OPERATION. Only one cart composition (synchronization, SP/PP writes, and cart switches share the same coordination) may run at a time. A rejected same-cart sync invalidates the known snapshot. Asking for a same-cart sync means the cart may have changed outside the SDK, so the in-flight old flow will not issue further writes from that snapshot. Requests already sent are still confirmed against the real server result. A rejected switch only rejects that request. The current binding and known snapshot stay unchanged, because the target cart was not accepted and that proves nothing about the current cart. Wait for the active operation to settle, then call syncCart again.
Internal sync steps (cart reads, SP/PP synchronization, and writes) share the operation’s permission and run in dependency order without re-entering the public lock or queueing against each other. SP and PP do not maintain their own queues. The SDK only coordinates operations it knows about. It cannot stop other tabs or direct requests that bypass it.
Do not read getCartQuotes() or enable checkout buttons until syncCart resolves. When the cart add-protection button is enabled, getCartQuotes() rejects with PP_CART_QUOTES_NOT_READY (not synced, sync in progress, or a stale snapshot), PP_CART_QUOTES_SYNC_FAILED (the previous sync failed), or CART_NOT_BOUND instead of returning stale candidates, and window.xcotton.sp may not be initialized yet.
After the SDK writes the cart
Section titled “After the SDK writes the cart”When the SDK modifies the cart, it invokes the onCartChange(cart) callback registered in init. Key behaviors to keep in mind:
- Timing: the callback fires after the SDK confirms cart facts, and its argument is a confirmed
Cartsnapshot. It does not guarantee that PP cart quotes are ready, sogetCartQuotes()may still reject withPP_CART_QUOTES_NOT_READYorPP_CART_QUOTES_SYNC_FAILED. Receiving the callback does not prove the sync completed, the operation succeeded, or that checkout may proceed. - Frequency: each SDK operation notifies at most once, passing the final confirmed cart snapshot. If a write fails mid-flight but the SDK’s recovery read succeeds,
onCartChangestill fires once with the actual server state. - Silent operations: read-only calls do not trigger
onCartChange. A confirmed cart-level attribute (attributes) write does trigger it. A successfulsyncCartthat confirms the cart firesonCartChangeonce, and multiple internal writes inside one composition are merged into a single notification. When no snapshot can be confirmed (for example, the cart read failed), nothing is notified and the previous cart is not passed off as current. - Fire-and-forget: the SDK does not await the Promise returned by
onCartChange. Exceptions or rejections inside your callback won’t affect the SDK call, so always catch async errors inside the callback. - Payload shape: the
cartargument contains only the fields used by the SDK. See Cart. UI-specific fields such as product images orcheckoutUrlare omitted. Re-run your own storefront query inside the callback if you need them.
We recommend having your cart page and cart drawer subscribe to a single shared cart store, and updating that store inside onCartChange. Both views stay in sync automatically without custom completion events:
await window.xcotton!.init({ onCartChange: () => { void reloadCartView().catch(showCartError) },})Partial writes and failure states
Section titled “Partial writes and failure states”A failed request does not prove the cart was left untouched. The SDK distinguishes four write states by evidence: not sent, explicitly rejected without effect, partially confirmed, and unknown. It reports them in context.writeOutcome:
- Write sent but response lost or timed out: re-read the cart first. Never replay the same write. A timeout only means the SDK stopped waiting, not that the server skipped the write.
- Write acknowledged but the complete cart could not be read: the SDK performs a single read-only recovery read to confirm the facts and does not resend the mutation. A successful recovery read does not turn the original failure into success.
- Pagination cursor, cart identity, duplicate-line, or cross-page cart-level fact anomalies during a default-adapter read: discard the aggregated result and re-read from the first page a bounded number of times, without replaying writes. A custom adapter owns its cross-page consistency. This reduces the mixed-snapshot risk. It is not a transactional guarantee across pages.
- When a public operation fails, confirmed cart facts may still be delivered through
onCartChange(for example, the main product was written while protection failed). Use the callback only to refresh the UI. The operation’s Promise still decides whether the business step completed.
Common mistakes
Section titled “Common mistakes”| Anti-pattern | Consequence | Correct approach |
|---|---|---|
Calling syncCart inside onCartChange | Triggers onCartChange again, causing an infinite loop of cart and quote requests | Only refresh your UI inside onCartChange |
Emitting SDK writes as storefront cart events into syncCart | Produces the same infinite loop | Emit storefront cart events only for your own mutations |
Omitting syncCart after a storefront cart mutation | Quotes and fee lines remain stale (for example, a fee line lingers after the cart is emptied) | Call syncCart(cart.id) after every successful storefront write |
| Synchronizing an old ID after switching carts | The SDK keeps operating on the previous cart | Pass the new cart ID after a switch |
| Tracking “the bound cart ID” locally and skipping sync when it matches | A mismatch with server state skips synchronization and adds a second identity to maintain | Always call syncCart(cart.id) and let the SDK decide |
| Refreshing only the component that triggered the click | The cart page and cart drawer go out of sync | Drive all cart views from a single shared cart state |
Reading product images or checkoutUrl from the callback’s cart | Those fields aren’t in the SDK’s Cart subset (undefined) | Run your storefront’s cart query inside the callback |
Dropping a Promise with void in onCartChange without .catch | Refresh failures become unhandled rejections and leave the UI stale | Catch and handle errors inside the callback |
| Automatically retrying a failed cart write | A timed-out write may have partially applied on the server, so retrying can duplicate line items | Re-read the cart first and let the customer decide |
Writing to the cart before syncCart finishes | The storefront write may use a stale snapshot. A later sync queues. A conflict with an internal write can report OPERATION_IN_PROGRESS | Wait for the current sync to settle and confirm the latest state before writing. You can call syncCart again. The SDK queues or merges it |
A custom adapter swallows a throttling error and returns null | The SDK treats the cart as missing and never enters its cooldown, so requests keep getting throttled | Reject an Error with at least status: 429 or code: 'THROTTLED', optionally with retryAfterMs |