SDK Reference
This page documents the entire public surface of the Headless SDK. Download xcotton-types.ts for complete TypeScript definitions, including global Window augmentations for window.xcotton and window.xcottonConfig. For step-by-step integration instructions, see the Quick start.
| Global object / namespace | When it becomes available |
|---|---|
window.xcottonConfig | Declared by your page before loading sdk.js |
window.xcotton | Immediately after sdk.js executes |
window.xcotton.pp | After init() resolves, when PP is enabled for the store |
window.xcotton.sp | After the first cart sync with a cartId completes (triggered by init({ cartId }) or syncCart()), when SP is enabled |
Global
Section titled “Global”window.xcottonConfig
Section titled “window.xcottonConfig”The startup configuration object read by the SDK. Must be defined on window before sdk.js runs.
interface XcottonConfig { shop: string country: string currency: string language: string publicAccessToken: string spWidgetSelector?: string ppWidgetSelector?: string}
window.xcottonConfig = { shop: 'example.myshopify.com', country: 'US', currency: 'USD', language: 'en', publicAccessToken: 'your-storefront-public-token', spWidgetSelector: '.xc-sp-container', ppWidgetSelector: '#xcotton-pp-panel',}| Field | Required | Description |
|---|---|---|
shop | Yes | Shopify store domain (for example, example.myshopify.com) |
country | Yes | Active market country code (such as US). Must match the cart’s buyer country and Shopify Markets |
currency | Yes | Active settlement currency code for the current market (such as USD). Must match the cart currency |
language | Yes | UI language code (such as en). Affects panel copy only, not the settlement currency |
publicAccessToken | Yes | Shopify Storefront API public access token |
spWidgetSelector | No | CSS selector for SP containers. A first match owns its panel until node removal. All panels share state and quotes |
ppWidgetSelector | No | CSS selector for the PP panel container |
currency is also the SDK’s currency reference: the cart, quotes, and written lines must all use it. When switching markets, prepare a cart for the new market and declare the new country / currency before loading the SDK on a new page. Changing window.xcottonConfig at runtime does not update the current session.
init(options)
Section titled “init(options)”Initializes the SDK and registers your onCartChange callback and optional adapter.
interface XcottonApi { init(opts?: XcottonInitOptions): Promise<void> syncCart(input?: string | Cart): Promise<Cart> isXcottonError(value: unknown): value is XcottonError sp?: SpApi pp?: PpApi}
interface XcottonInitOptions { cartId?: string adapter?: XcottonCartAdapter onCartChange?: (cart: Cart) => unknown}
const sdk = window.xcottonif (!sdk) throw new Error('[XC] sdk.js has not loaded yet')await sdk.init({ cartId: 'gid://shopify/Cart/your-cart-id', onCartChange() { void reloadCartView().catch(console.error) },})| Option | Description |
|---|---|
cartId | Optional. Pass an existing cart ID at startup to run the initial synchronization with init |
adapter | Optional. Overrides part or all of the built-in Storefront API cart client. See XcottonCartAdapter |
onCartChange | Optional. Invoked after a confirmed SDK cart write and when each syncCart settles, receiving a confirmed Cart. It does not run without a confirmed snapshot. See Keep the cart in sync |
- One-shot entry point:
initruns exactly once and does not handle refresh, binding, cart switching, or reconfiguration. Calling it again while initialization is in progress, after success, after an environment initialization failure, or after a failed first sync rejects:SDK_INIT_IN_PROGRESSwhile in progress, otherwiseSDK_ALREADY_INITIALIZEDwithcontext.phaseset toready,environment_failed,sync_failed, orbusy. It never reuses the original Promise and never counts as an idempotent success. - Binding, refresh, and cart switching:
initis not a second cart entry point. After a cart-less initialization succeeds, callsyncCart(cartId)to bind the first cart. Refresh and cart switches also go throughsyncCart. A late-arrivingcartIdis not bound by callinginitagain. - Cart-optional startup: can be called before a cart exists (PP product pricing does not require a cart).
- Two-stage recovery: if the environment fails to initialize (including PP being enabled but failing to start), the SDK keeps no half-initialized surface. Correct the cause and reload the page.
syncCartcannot bypass that failure. If the environment is ready but the first cart read or business sync fails, recover withsyncCart(cartId)from the real cart state without replaying previous writes, and do not callinitagain. - First sync busy: when the first sync conflicts with another cart operation in flight,
initrejects withOPERATION_IN_PROGRESS. A laterinitcall returnsSDK_ALREADY_INITIALIZEDwithcontext.phaseset tobusy. Wait for the active operation to settle, then bind withsyncCart(cartId). Do not callinitagain. - Error handling:
initrejects with anXcottonError(SDK_INIT_FAILEDfor the environment stage,SDK_INIT_SYNC_FAILEDfor the first sync stage,OPERATION_IN_PROGRESSwhen the first sync is busy). Themessagestarts with[XC]and names the failing module, stage, and cause. See Error handling.
isXcottonError(value)
Section titled “isXcottonError(value)”Returns whether a value is an XcottonError thrown by the SDK. Failures from public SDK methods, including before initialization, use this shape. Site functions and script-load errors do not. The guard does not rely on instanceof, so it also works across bundle boundaries and realms.
const sdk = window.xcottonif (!sdk) throw new Error('[XC] sdk.js has not loaded yet')
try { await sdk.syncCart('gid://shopify/Cart/your-cart-id')} catch (error) { if (sdk.isXcottonError(error)) { console.warn(error.code, error.context, error.recovery.action) } else { console.error(error) }}- Parameter: any value.
- Returns:
value is XcottonError. Whentrue, you can safely readcode,message,context,recovery, andcause. - Do not test with
instanceof XcottonError: remote modules and your storefront may run in different bundles or realms.
syncCart(cartId?)
Section titled “syncCart(cartId?)”The single cart synchronization entry point: use it for the first bind, for switching carts, and for refreshing the same cart. The SDK decides which case applies, so your storefront keeps no second binding identity and no synchronization queue.
const sdk = window.xcottonif (!sdk) throw new Error('[XC] sdk.js has not loaded yet')await sdk.syncCart('gid://shopify/Cart/your-cart-id')- Pass a Storefront cart GID (such as
gid://shopify/Cart/...) or a complete Cart that satisfies the SDK field contract. Omit it to synchronize the bound cart. Without a bound cart, the call rejects. A supplied Cart is validated for identity, currency, and complete pagination before adoption. - The Promise returns an independent Cart after required business synchronization completes. A failed V4 request or protection quantity update rejects, so checkout must stay blocked. A failed operation may still notify confirmed cart facts. Callback counts do not represent operation counts.
- On the first bind and whenever
cartIdchanges, the SDK reconciles the fee line according to the active integration mode. See When a cart is first synchronized. window.xcotton.spis attached only after the firstsyncCartwith a cart ID finishes.- Concurrency and merging: public
syncCartcalls run in order. Adjacent concurrent calls with the same input merge into one sync and share its result. A call that overlaps another internal operation already holding cart permission rejects withOPERATION_IN_PROGRESS. A known external change to the same cart invalidates old facts and work. A rejected request for another cart neither changes the binding nor indicates that the current cart changed. Wait for the active operation to finish before deciding whether to synchronize again. - Currency guard: the synchronized cart total must use the
currencyconfigured at initialization. Otherwise the call rejects instead of quoting or writing with the wrong currency. - Never call
syncCartinsideonCartChange, or you will create an infinite loop.
XcottonCartAdapter
Section titled “XcottonCartAdapter”Optional custom cart adapter for storefronts that route cart reads and writes through their own API or BFF. You can implement a subset of methods. Any omitted method uses the built-in Storefront API implementation.
interface XcottonCartAdapter { getCart?(cartId: string): Promise<Cart | null> addLines?(cartId: string, lines: CartLineInput[]): Promise<Cart> updateLines?(cartId: string, lines: CartLineUpdateInput[]): Promise<Cart> removeLines?(cartId: string, lineIds: string[]): Promise<Cart> updateAttributes?(cartId: string, attributes: AttributeInput[]): Promise<Cart>}
interface CartLineInput { merchandiseId: string quantity?: number | null attributes?: AttributeInput[] | null}
interface CartLineUpdateInput { id: string quantity?: number | null attributes?: AttributeInput[] | null}
interface AttributeInput { key: string value: string}| Method | Implementation requirements |
|---|---|
getCart | Return null only when the cart is confirmed not to exist. Reject on network, auth, or rate-limit errors |
addLines | merchandiseId is a full ProductVariant GID.quantity defaults to 1 when omitted |
updateLines | id is the current CartLine.id. Supply quantity, attributes, or both (quantity: 0 removes the line) |
removeLines | Preferred method for removing cart lines |
updateAttributes | Always receives the complete cart attribute array ([] clears all attributes) |
Every write method must resolve with the complete updated Cart. For line attributes, omitting or passing null keeps existing attributes, [] clears them, and a non-empty array replaces the entire list. For complete contract and validation rules, see Use your own cart API.
Currency guard: the built-in and custom adapters follow identical rules. The SDK checks that cart.cost.totalAmount.currencyCode matches the currency configured at initialization, and that every money field uses that same currency. A mismatch rejects before quoting or writing. Custom adapters must not convert or replace currencies.
Rate-limit metadata: when your API is throttled, reject with an Error that keeps the following fields so the SDK can enter its cooldown and reject cart requests during it:
const error = new Error('Cart API throttled')Object.assign(error, { status: 429, code: 'THROTTLED', retryAfterMs: 2000 })throw errorThe SDK recognizes throttling from status: 429, code: 'THROTTLED', or 429 / THROTTLED in message. Without retryAfterMs it applies a 30-second cooldown. If none of the three signals is present, the error is handled as a regular adapter failure and no cooldown starts. Do not swallow a throttling error and return null, and do not retry on your own.
The built-in adapter, custom adapters, and onCartChange all share a single Cart type, a focused subset of the Storefront API Cart schema. See xcotton-types.ts for the full definition.
| Field path | Description |
|---|---|
id | Storefront cart GID |
lines.nodes | All cart lines (every pagination page must be fetched) |
lines.pageInfo | Pagination metadata.hasNextPage must be false when returned to the SDK |
lines.nodes[i].id | Line ID (CartLine.id). Distinct per line even for the same variant. May change after attribute updates |
lines.nodes[i].__typename | 'CartLine' or 'ComponentizableCartLine' (bundles are priced by the parent line) |
lines.nodes[i].cost.amountPerQuantity | Unit price |
lines.nodes[i].cost.subtotalAmount | Pre-discount line subtotal |
lines.nodes[i].cost.totalAmount | Payable line total (preserve zero amounts, and do not substitute fallback prices) |
lines.nodes[i].merchandise | ProductVariant: GID, SKU, title, and requiresShipping |
lines.nodes[i].merchandise.product | Parent product: id, title, handle, vendor, productType, isGiftCard |
attributes | Cart-level attributes (value may be null when read, must be a string when written) |
cost.totalAmount | Cart total as MoneyV2 (amount is a decimal string, currencyCode is the currency) |
discountCodes | Applied discount codes and their applicable status |
UI-only fields such as product images and checkoutUrl are not part of Cart. Fetch them through your own storefront query when needed.
window.xcotton.sp becomes available after the first syncCart with a cart ID resolves. It remains undefined when SP is disabled for the store.
interface SpApi { addSpFee(): Promise<{ added: true; extId: number }> removeSpFee(): Promise<{ removed: boolean }> declineSpFee(): Promise<{ declined: true }> resolveSP(): Promise<SpResolveResult> getQuote(): Promise<SpQuote> getMode(): 'cart' | 'checkout' | 'checkoutPlus' showModal(): Promise<void>}
const sp = window.xcotton?.spif (!sp) throw new Error('[XC] SP is not ready')| Method | Purpose | Writes cart | Typical mode |
|---|---|---|---|
addSpFee() | Writes the fee line immediately. Idempotent when the fee is correct | Yes | checkout+ with protection, custom toggle |
removeSpFee() | Closes intent and removes the fee line. No line present is not an error | Yes | Custom toggle, custom “without protection” |
declineSpFee() | Explicitly declines this checkout: clears the fee and writes the refusal marker | Yes | checkout+ without protection |
resolveSP() | Aligns the fee line with the customer’s switch intent and returns the action | Yes | cart, checkout |
getQuote() | Reads the current SP quote. Rejects when protection is unavailable | No | All modes |
getMode() | Returns the effective mode | No | All modes |
showModal() | Opens the SP coverage details modal (requires a mounted panel) | No | All modes |
All methods are available in all three modes (resolveSP() rejects in checkout+). The “typical mode” column describes the intended use.
sp.addSpFee()
Section titled “sp.addSpFee()”Explicitly requests the protection fee immediately, including the checkout+ “checkout with protection” entry. The SDK aligns the fee line with the current quote and succeeds idempotently when the correct fee already exists.
const sp = window.xcotton!.sp!const { added, extId } = await sp.addSpFee()// added is always true; extId is the confirmed SP variant ID (not a cart line ID) and is never 0- The promise rejects when the cart is not insurable (empty cart, subtotal outside the coverage range) or the write fails. It no longer resolves
added: false. Handle the errorcode/recoveryinstead of assuming success. - The result does not include a full Cart.
onCartChangereports the confirmed cart facts.
sp.removeSpFee()
Section titled “sp.removeSpFee()”Closes the protection intent and removes the fee line immediately.
const sp = window.xcotton!.sp!const { removed } = await sp.removeSpFee()removed: truemeans the fee line was confirmed removed.removed: falsemeans the cart had no fee line. Both are normal completion.- Even without a fee line the SDK still closes the intent and writes the required markers. A failed marker write rejects the promise.
- Unlike
declineSpFee(),removeSpFee()is a state operation (“turn protection off”), not the customer’s explicit “checkout without protection” choice.
sp.declineSpFee()
Section titled “sp.declineSpFee()”Explicitly declines this checkout: clears the fee line and writes the refusal marker.
const sp = window.xcotton!.sp!const { declined } = await sp.declineSpFee()// declined is always true; failures reject the promise| Mode | Intent handling |
|---|---|
cart | Closes and persists the switch intent so the next sync does not re-add the fee |
checkout | Keeps the customer’s switch intent. This decline does not persist it as closed |
checkout+ | Never reads, writes, or renews the switch cache. The two buttons express the choice |
- Once the fee removal and markers succeed, an unrelated re-quote failure does not turn the decline into a failure.
- Do not chain
resolveSP()after a decline:checkoutkeeps the original intent, so the next resolve would add protection again.
sp.resolveSP()
Section titled “sp.resolveSP()”Aligns the fee line with the customer’s intent and returns the action result. The storefront decides whether to navigate.
type SpResolveResult = | { action: 'added'; extId: number } | { action: 'removed' } | { action: 'skipped'; protection: 'included'; extId: number } | { action: 'skipped'; protection: 'excluded' }
const sp = window.xcotton!.sp!const result = await sp.resolveSP()// added, removed, and skipped (the fee line already matches) all continue to checkout. A rejection never reaches here.if (result.action === 'added' || result.action === 'removed' || result.action === 'skipped') { window.location.href = '/checkout'}added: the fee line was written or adjusted.extIdis the confirmed SP variant ID.removed: the fee line was removed.skipped / included: the fee line already matches the intent (required markers may still be written).skipped / excluded: the customer does not want protection and the cart has no fee line.- When the intent is on but the cart is not insurable the promise rejects with a specific business error (for example
SP_NOT_AVAILABLE). It is never reported asskipped. Read, quote, write, and confirmation failures also reject. - In
checkout+there is no switch intent, so the call rejects withINVALID_ARGUMENT. CalladdSpFee()ordeclineSpFee()instead.
sp.getMode()
Section titled “sp.getMode()”Synchronously returns the integration mode configured by the account manager (AM). 'checkoutPlus' is the checkout+ mode described in these docs.
sp.getQuote()
Section titled “sp.getQuote()”Reads the current SP quote without changing cart attributes or cart lines, or taking the purchase flow’s write permission. It rejects if the cart session or known facts become invalid during the read, so it cannot return an old cart’s quote. Returns SpQuote:
{ "extId": 45678901234, "totalPrice": "1.99", "totalPriceInt": 199, "currencyCode": "USD", "currencySymbol": "$"}- A resolved quote means the protection is currently available and
extIdis valid. An unavailable cart rejects with a specific business error (for exampleSP_NOT_AVAILABLE, withcontext.reason).extId: 0andinRangeno longer encode failure. - A valid zero-amount quote requires a valid
extIdand a numerictotalPriceIntof 0. Missing, non-finite, negative, or fractional amounts reject instead of becoming 0. An uninsurable cart is not a zero-amount quote. - A quote snapshot is not permanently valid. The SDK re-confirms against the current cart when writing the fee.
sp.showModal()
Section titled “sp.showModal()”Opens the SP coverage details modal through the already-mounted panel.
- At least one SP panel must be mounted and connected. Otherwise the promise rejects with
SP_PANEL_REQUIRED(recovery.action: MOUNT_SP_PANEL). - The promise resolves once the modal has rendered. It does not wait for the customer to close it and does not mean protection was purchased.
- Opening does not change intent or write the cart. Multiple panels share one modal, and it closes when the last panel unmounts.
- The modal button uses the internal intent entry:
cartexpresses intent and writes the cart,checkoutonly expresses intent, andcheckout+does not show the intent button.
SpQuote
Section titled “SpQuote”interface SpQuote { extId: number totalPrice: string totalPriceInt: number currencyCode: string currencySymbol: string}| Field | Description |
|---|---|
extId | SP variant ID. A resolved quote always has a valid value, 0 is no longer a success value |
totalPrice | Display string in the major currency unit. Below 10000 in the fixed ×100 protocol scale, it keeps 2 decimals. At or above 10000 it rounds to whole units and loses the fraction |
totalPriceInt | Exact amount as an integer in “major unit × 100” (independent of the currency’s decimal places). Use it for all calculations and comparisons |
currencyCode | Currency code (for example 'USD', 'CAD'). The currency identity is defined by this field |
currencySymbol | Display-only symbol (for example '$'). CAD and USD both use $ |
SpResolveResult
Section titled “SpResolveResult”| Branch | Meaning |
|---|---|
{ action: 'added', extId } | The fee line was written or adjusted.extId is the SP variant ID |
{ action: 'removed' } | The fee line was removed |
{ action: 'skipped', protection: 'included', extId } | An existing fee line already matches the intent |
{ action: 'skipped', protection: 'excluded' } | The customer wants no protection and no fee line exists |
window.xcotton.pp becomes available as soon as init() resolves, with no cart required. It remains undefined when PP is disabled for the store.
The product-page convenience path is pp.addToCart. The methods below remain as atomic primitives for a custom add-to-cart UI or a storefront-drawn cart button, and compose to the same flow.
interface PpApi { loadPlans( variantId: string | number, variantDetail?: PPVariantDetail, ): Promise<PPPlansResult> loadPlans(variantId: null, variantDetail?: PPVariantDetail): Promise<null> canBePopup(context?: PPProductContext): Promise<boolean> getSelectedPlan(context?: PPProductContext): Promise<PPSelection | null> setSelectedPlan( selection: PPSelection | null, context?: PPProductContext, ): Promise<void> showModal(context?: PPProductContext): Promise<PPSelection | null> addProtection(params: { mainLineId: string selection: PPSelection }): Promise<{ added: true }> removeProtection(params: { mainLineId: string }): Promise<PPRemoveProtectionResult> getCartQuotes(): Promise<PPCartQuotesResult> getRelations(): Promise<PPRelation[]> offerProtection(params: { mainLineId: string }): Promise<PPOfferProtectionResult> addToCart(options: PPAddToCartOptions): Promise<PPAddToCartResult>}| Method | Purpose | Requires a synchronized cart |
|---|---|---|
loadPlans() | Fetches quotes for the active variant and renders the PP panel | No |
canBePopup() | Reads whether the automatic modal condition is met | No |
getSelectedPlan() | Reads the current selection without opening a modal | No |
setSelectedPlan() | Sets or clears the current selection and updates the panel | No |
showModal() | Opens the plan selection modal on demand | No |
addProtection() | Writes protection for an explicit plan on a specific main line | Yes |
removeProtection() | Cancels protection for a main product line | Yes |
getCartQuotes() | Returns cart lines that can still add protection, with their quotes | Yes |
getRelations() | Returns mappings between protected main lines and protection lines | Yes |
offerProtection() | Cart add-protection orchestration: validate, open the modal, write | Yes |
addToCart() | PDP convenience add-to-cart: selection, main product, sync, protect | No (the callback may create a cart) |
Product-level quoting, selection, and modal methods never create or bind a cart. Cart protection methods require a bound cart.addToCart() may bind a cart created by its main-product callback.
Product-level quoting, selection, and modal methods never create or bind a cart. Cart protection methods require a bound cart.addToCart may bind a cart created by its main-product callback.
pp.addToCart({ context?, addMainProduct })
Section titled “pp.addToCart({ context?, addMainProduct })”Convenience add-to-cart for product pages. The SDK locks the current product context, resolves the selection, calls your main product write once, then synchronizes the cart and writes protection. It performs the same business as the manual chain (getSelectedPlan() → write main product → syncCart() → addProtection()). Choose either path to fit your storefront.
When context is omitted, the SDK uses the product context that is still active when the call starts. Do not call this method from pages without a loadPlans context, such as a home page or a listing-page quick add. Write the main product and call syncCart() instead, and leave the protection entry to the cart. A missing context rejects with INVALID_ARGUMENT (path=context) and points to the plain add-to-cart path.
interface PPAddToCartOptions { context?: PPProductContext addMainProduct: ( attributes: AttributeInput[], context: PPProductContext, ) => Promise<{ cartId: string; mainLineId: string }>}
type PPAddToCartResult = { cartId: string; mainLineId: string } & { protection: 'added' | 'skipped'}
declare function addMainProduct( variantId: string, quantity: number, attributes: AttributeInput[],): Promise<{ cart: { id: string }; mainLine: { id: string } }>
const sdk = window.xcottonif (!sdk?.pp) throw new Error('[XC] Product Protection is not enabled')const result = await sdk.pp.addToCart({ addMainProduct: async (attributes, context) => { const { cart, mainLine } = await addMainProduct( context.mainVariantId, 1, attributes, ) return { cartId: cart.id, mainLineId: mainLine.id } },})| Field | Description |
|---|---|
context | Optional. Omit it to use the global product context established by loadPlans when the call starts. Pass a handle to validate its identity and generation. Missing or stale context rejects |
addMainProduct | Callback that writes only the main product. Receives the line attributes (AttributeInput[], empty when no plan is selected) and the locked context. Called exactly once |
cartId | Cart ID in the result, taken from the callback return value (the callback may create the cart) |
mainLineId | Main product line ID (CartLine.id) in the result. It can be a new line or an existing line Shopify merged into |
protection | added main product and protection are both confirmed.skipped no protection was selected and the main product plus sync completed |
Order of operations:
- Lock the product context. Use an existing selection as-is. When none is selected and the back office allows the modal, open it first (cancelling still adds the main product), then pass
selection.mainLineAttributesto the callback. - Call
addMainProduct(attributes, context)once. - Synchronize the cart with the returned
cartIdand re-read the cart to confirmmainLineIdmatches this context’s variant. - If there is a selection, write the protection line against the returned
mainLineId.
Callback and failure contract:
- The callback writes only the main product. Do not call the SDK from inside it (
init,syncCart,pp.addToCartincluded), do not read the current page variant, and do not reset the PDP quote. - The SDK never replays the callback and never rolls back a main product that succeeded. The callback runs at most once.
- Success resolves only
added/skipped. Every failure rejects with anXcottonError. When the main product was confirmed but sync or protection failed, the error isPP_WRITE_CONFIRMATION_FAILEDwithstage,mainProduct,protection, andwriteOutcomein its context. The main product stays in the cart, so do not invite the customer to add it again. Follow the recovery guidance and refresh the cart or retry only protection. - If operation permission expires before the callback starts, the SDK does not invoke it. Both
mainProductandwriteOutcomearenot_sent. If the callback ran but its result cannot be confirmed, the SDK uses available error metadata or reportsunknownwhen evidence is insufficient. This does not prove that the main product was or was not written. pp.addToCartandpp.offerProtectionshare the purchase-flow lock. Other selection or purchase entries reject withPP_SELECTION_IN_PROGRESSwhile it runs.
The purchase flow holds cart operation permission from selection through the main-product callback, synchronization, and protection confirmation. SP writes and external syncCart calls reject when they conflict. Do not call these SDK entries inside addMainProduct. Conflicts and timeouts never cause the SDK to replay the callback.
Product context PPProductContext
Section titled “Product context PPProductContext”loadPlans(variantId) quotes the variant, makes it the global current PP context, and resolves to { context, options }. context is a handle managed by the SDK:
- Omit
contextand the call captures the current global context when it starts. After awaiting it never drifts to a newer product. - Pass
contextexplicitly and the call belongs to that product only. An unknown handle (not returned by the SDK) rejects withINVALID_ARGUMENT. An expired handle (after switching variants, leaving the product page, or A → B → A) rejects withPP_CONTEXT_CHANGEDand never falls back to the global context. - Never construct a handle by hand or cache one across products.
const variantId = 'gid://shopify/ProductVariant/1234567890'const { context, options } = await window.xcotton!.pp!.loadPlans(variantId)await window.xcotton!.pp!.canBePopup(context)await window.xcotton!.pp!.getSelectedPlan(context)
// A single-product page may omit context.await window.xcotton!.pp!.canBePopup()All objects returned by PP methods are defensive snapshots. Mutating them never affects internal SDK state.
pp.loadPlans(variantId, variantDetail?)
Section titled “pp.loadPlans(variantId, variantDetail?)”Prices the product variant currently selected on the product page, renders the PP panel, and resolves to { context, options }:
-
context: an SDK-managed product context handle you can pass to the selection methods. It can also be omitted to use the global current context. -
options:PPSelection[]. Each entry carries the plan andmainLineAttributesand can be submitted tosetSelectedPlandirectly, or read by a custom add-to-cart UI. -
variantId: main product variant ID (string or number). The SDK resolves the product and price for the market configured at initialization. -
Passing
nullsignals leaving the product context: the SDK cancels any pending quote, closes any open plan modal, clears the PP panel and the current selection, and resolves tonull. -
A successful quote with no available plan resolves to
{ context, options: [] }. The context stays valid (canBePopup()isfalse,getSelectedPlan()isnull, andshowModal()rejects withPP_NO_OPTIONS). -
A new call cancels any in-flight quote. A call superseded by a newer context rejects with
PP_CONTEXT_CHANGEDinstead of resolving to an empty plan list. -
Re-quoting the same variant keeps a still-valid selection. After the customer explicitly deselects, a re-quote does not reselect the
hasSelectedDefaultplan. -
variantDetail(optional): pass when your storefront already holds the variant details for the active market to save one Storefront API lookup.
interface PPVariantDetail { product: { id: string } title: string sku?: string | null price: { amount: string; currencyCode: string }}| Field | Description |
|---|---|
product.id | GID of the product that owns the variant. Plans are resolved by it |
title | Variant title |
sku | Optional variant SKU |
price.amount | Variant price in the active market as a Storefront decimal string (such as '29.99'), not cents |
price.currencyCode | Price currency. Must match the currency configured at initialization |
The SDK converts amount to an integer using the existing quote protocol’s fixed ×100 scale and validates the currency. A failed request, a backend rejection, or a response without a plan identity rejects with a specific error and never resolves to an empty plan list.
pp.canBePopup(context?)
Section titled “pp.canBePopup(context?)”Reads whether the current product meets the back-office “open the modal on add to cart” condition. It never opens a modal, changes the selection, or writes the cart.
- Returns
falsewhen the setting is off or no plan is available.trueonly means the condition held at query time. - Waits for the latest quote of the same variant before answering.
- A quote or configuration failure rejects with a specific error instead of pretending to be
false. - Switching variants or leaving the product page while waiting rejects with
PP_CONTEXT_CHANGED.
pp.getSelectedPlan(context?)
Section titled “pp.getSelectedPlan(context?)”Read-only: returns the selection established for the current variant, or null when nothing is selected or the customer cleared it.
- Never selects a plan, opens a modal, or writes the cart.
- Waits for the latest quote of the same variant and never returns a plan from a previous price.
- An invalidated context (variant switch, page leave, A → B → A) rejects with
PP_CONTEXT_CHANGED.nullonly means “confirmed no selection”.
pp.setSelectedPlan(selection, context?)
Section titled “pp.setSelectedPlan(selection, context?)”Sets or clears the current product selection and updates the PP panel. It never opens a modal, adds to cart, or writes the cart.
- Pass an option from
options: the SDK verifies the candidate belongs to the target product and still matches the current quote before committing it. Do not assemble plans or line attributes yourself. - Pass
null: clears the selection (same as a customer deselect). A later re-quote for the same variant does not reselect the default plan. - Setting the same valid candidate again, or clearing again, resolves successfully. Idempotency never bypasses validation.
- Failures are specific: a foreign or stale plan rejects with
INVALID_ARGUMENT(context.pathpoints at a specificselectionfield, such asselection.mainVariantId,selection.plan.variantId, or a plan content field). An invalidated context rejects withPP_CONTEXT_CHANGED. - While a purchase flow or the selection modal is running, it rejects with
PP_SELECTION_IN_PROGRESSand does not rewrite the selection being confirmed.
const variantId = 'gid://shopify/ProductVariant/1234567890'const index = 0const { context, options } = await window.xcotton!.pp!.loadPlans(variantId)// When the customer picks one, submit the SDK-provided option as-is.await window.xcotton!.pp!.setSelectedPlan(options[index]!, context)const selection = await window.xcotton!.pp!.getSelectedPlan(context)// When the customer deselects, clear it; the next add to cart still follows the back-office modal setting.await window.xcotton!.pp!.setSelectedPlan(null, context)A chosen plan resolves to an independent PPSelection:
{ "mainVariantId": "gid://shopify/ProductVariant/1234567890", "plan": { "variantId": 45678901234, "name": "1 Year", "priceText": "$4.99" }, "mainLineAttributes": [{ "key": "_PlanID", "value": "…" }]}pp.showModal(context?)
Section titled “pp.showModal(context?)”Captures the product context when called, awaits its latest quote, and unconditionally opens the plan selection modal, bypassing the automatic modal setting.
- Confirming updates the SDK selection and the PDP panel and resolves to an independent
PPSelection. Nothing is added to the cart. - Cancelling, closing, or pressing Escape resolves to
nulland clears the PDP selection. The cancellation is not recorded as a refusal: while nothing is selected, the next add to cart still follows the back-office modal setting. - With no available plan it rejects with
PP_NO_OPTIONSinstead of resolving tonull. - Switching variants or leaving the product page while waiting rejects with
PP_CONTEXT_CHANGED. Once the modal is open, any re-quote closes it and rejects withPP_CONTEXT_CHANGED, so the customer can never confirm a stale price. A later context’s selection is left untouched. - Selection exclusivity starts with waiting for the quote and ends on confirm, cancel, or failure: calling
showModal()orsetSelectedPlan()while it runs rejects immediately withPP_SELECTION_IN_PROGRESS(no queueing, no stacked modals).
A plan confirmed in the modal stays in the panel state, so the next pp.addToCart resolves the same selection. Your storefront neither stores the choice nor rewrites the main product attributes.
pp.addProtection({ mainLineId, selection })
Section titled “pp.addProtection({ mainLineId, selection })”Writes protection for an explicit plan on the specified main product line. Resolves to { added: true }.
mainLineId: the main product line’sCartLine.id(never a variant ID).selection: required, from validloadPlansoptions orgetCartQuotescandidates. Omitting it rejects withINVALID_ARGUMENT. The variant must match the target main line, and the plan is validated again before writing. Do not construct plans yourself or submit stale options.- Opens no modal and never borrows the PDP selection or a hidden candidate. Repeating the same plan is an idempotent success, and quantity is recomputed from the real cart across the shared group of the same variant and Plan ID. A line protected with a different plan rejects
PP_ALREADY_PROTECTED; callremoveProtection()first to switch. - A missing main line or a protection line ID rejects
CART_LINE_NOT_FOUND. Invalid plan/main-line identity and write/confirmation failures each have specific errors. - Calling it while a purchase flow runs rejects
PP_SELECTION_IN_PROGRESS. The SDK’s own composed flows are not rejected by their own lock.
pp.removeProtection({ mainLineId })
Section titled “pp.removeProtection({ mainLineId })”Cancels protection for a main product line. Resolves to { status: 'removed' }, or { status: 'already_unprotected' } when the line has neither protection nor a link marker. mainLineId is the main line’s CartLine.id (not the protection line). The main product stays. The SDK clears only that line’s _PlanID, keeps your other attributes, and recomputes protection quantity across the remaining group.
After confirming cancellation, the SDK restores the line’s available offers as needed. A failed offer refresh does not undo removed.getCartQuotes() rejects until you call syncCart() after the service recovers.
pp.offerProtection({ mainLineId })
Section titled “pp.offerProtection({ mainLineId })”Cart add-protection orchestration. Resolves { status: 'added' } or { status: 'cancelled' }.
- Checks the cart add-protection switch, the main line, and the current valid quote in that order. A disabled switch or no candidate for that line rejects
PP_NO_OPTIONS, never a customer cancellation. - Even a single plan opens the modal. Only after the customer confirms does it reuse
addProtection. Cancelling resolves{ status: 'cancelled' }with zero writes. - A line with real protection rejects
PP_ALREADY_PROTECTED(based only on actual protection lines in the current cart, where a residual_PlanIDis not protection). Ambiguous relations or quantity anomalies rejectCART_SNAPSHOT_INCONSISTENTinstead. - The selection comes from that main line’s cart quote and never switches or clears the PDP context or selection. If the main-line facts or quote become stale while the modal is open, the old selection rejects without writing. Synchronize again before starting another offer.
- Shares the purchase-flow lock with
pp.addToCart: other selection or purchase entries rejectPP_SELECTION_IN_PROGRESSfor the whole flow, including the write-confirmation stage.
pp.getCartQuotes()
Section titled “pp.getCartQuotes()”Renamed from getCartOffers. Returns the PP cart quotes computed during the latest cart sync. These are main product lines in the cart that can still have protection added. Read-only in-memory read. It sends no network request.
{ "enabled": true, "quotes": [ { "mainLineId": "gid://shopify/CartLine/abc123", "quantity": 1, "options": [ { "mainVariantId": "gid://shopify/ProductVariant/1234567890", "plan": { "variantId": 45678901234, "name": "1 Year", "priceText": "$4.99" }, "mainLineAttributes": [{ "key": "_PlanID", "value": "…" }] } ] } ]}enabledonly reports that the back-office in-cart add-protection switch is confirmed on. When it is off the result is{ enabled: false, quotes: [] }. When it is on but there are no candidates the result is{ enabled: true, quotes: [] }. The two empty results are not interchangeable.- An uninitialized SDK rejects with
SDK_NOT_INITIALIZED, and no bound cart rejects withCART_NOT_BOUND. Not synchronized yet, sync in progress, and a stale snapshot each reject withPP_CART_QUOTES_NOT_READY, withcontext.reasonset tonot_synced,sync_in_progress, orstale. A failed latest sync rejects withPP_CART_QUOTES_SYNC_FAILED. An empty array never stands in for a failure. - Each entry’s
optionsmatch the product-page quote shape and are completePPSelectionobjects that can be submitted directly toaddProtection. After a storefront cart write, awaitsyncCart()before reading. A previous generation’s result does not represent the current cart. - When the read rejects, clear the previous candidates before showing the error. Do not leave an “Add protection” button that can only fail again. Read again after the service recovers and
syncCart()completes.
pp.getRelations()
Section titled “pp.getRelations()”Returns all protected main product lines in the current cart along with their linked protection lines. Unprotected lines are omitted. Because multiple main product lines of the same variant and plan share a single aggregated protection line, multiple entries can point to the same protectionLineId.
[ { "mainLineId": "gid://shopify/CartLine/abc123", "protectionLineId": "gid://shopify/CartLine/def456" }]To render a “Remove protection” action alongside a main product line, match the line by mainLineId and pass that mainLineId to removeProtection({ mainLineId }).protectionLineId is for display only. When no cart is bound the call rejects with CART_NOT_BOUND. A read failure rejects with CART_SYNC_FAILED or the adapter’s specific error. Ambiguous protection groups reject with CART_SNAPSHOT_INCONSISTENT. A confirmed absence of relations resolves to []. Never use an empty array for a failure.
PP types
Section titled “PP types”interface PPProductContext { readonly mainVariantId: string}
interface PPPlansResult { context: PPProductContext options: PPSelection[]}
interface PPSelection { mainVariantId: string plan: PPPlanItem mainLineAttributes: AttributeInput[]}
interface PPPlanItem { variantId: string | number name?: string priceInt?: number priceText?: string properties?: Record<string, string> [key: string]: unknown}
interface PPCartQuote { mainLineId: string quantity: number options: PPSelection[]}
type PPCartQuotesResult = | { enabled: false; quotes: [] } | { enabled: true; quotes: PPCartQuote[] }
interface PPRelation { mainLineId: string protectionLineId: string}
type PPRemoveProtectionResult = | { status: 'removed' } | { status: 'already_unprotected' }
type PPOfferProtectionResult = { status: 'added' } | { status: 'cancelled' }
interface PPAddToCartOptions { context?: PPProductContext addMainProduct: ( attributes: AttributeInput[], context: PPProductContext, ) => Promise<{ cartId: string; mainLineId: string }>}
type PPAddToCartResult = { cartId: string; mainLineId: string } & { protection: 'added' | 'skipped'}| Type / field | Description |
|---|---|
PPProductContext.mainVariantId | ProductVariant GID behind a context handle. The handle is issued by the SDK and must not be constructed by hand |
PPPlansResult.options | Full plan snapshot of the current quote. Every entry can be submitted to setSelectedPlan |
PPSelection.mainVariantId | ProductVariant GID of the main product this selection belongs to. Check it against the target variant fixed at click time before writing the main product |
PPSelection.plan | The selected protection plan |
PPSelection.mainLineAttributes | Main line attributes as Storefront AttributeInput[]. The site passes them through during composed purchase, while direct addProtection fills in the association marker |
PPPlanItem.variantId | Variant ID of the protection plan |
PPPlanItem.name | Display name of the plan (such as '1 Year'). Not unique across products. Do not use it to link lines |
PPPlanItem.priceInt | Plan price as an integer in the fixed ×100 protocol scale |
PPPlanItem.priceText | Formatted price string (such as '$4.99') |
PPCartQuote.mainLineId | CartLine.id of an unprotected main product line eligible for protection |
PPCartQuote.quantity | Quantity of that main product line |
PPCartQuote.options | Selectable PPSelection entries for that line. Submit one directly to addProtection |
PPRelation.mainLineId | CartLine.id of a protected main product line |
PPRelation.protectionLineId | CartLine.id of its linked protection line (display only, and pass mainLineId to cancel protection) |
PPOfferProtectionResult | added protection written and confirmed.cancelled customer cancelled with zero writes |
PPAddToCartResult.protection | added main product and protection both completed.skipped no protection selected this time |
Error messages
Section titled “Error messages”Error handling
Section titled “Error handling”Failures from public SDK methods use a single XcottonError shape (name === 'XcottonError'). Before initialization the code is SDK_NOT_INITIALIZED. Site functions, a failed sdk.js load, or a missing window.xcotton raise ordinary exceptions outside this contract:
interface XcottonError { name: 'XcottonError' code: XcottonErrorCode operation: string message: string context: { code: XcottonErrorCode } & Record<string, unknown> recovery: { action: XcottonRecoveryAction; message: string } cause?: unknown}codeis the value your integration should branch on.context.codematches it, and narrowing bycodeexposes the fields specific to that failure.messagestarts with[XC]and includes the specific cause. Use it for logs and display only. Never branch on message text.- Use the returned
code,context, and availablecauseto identify the failure. Do not infer a more specific reason or write result than the upstream evidence provides.causemay be absent. recovery.actionsuggests what to do next (such asSYNC_CART,WAIT_AND_RETRY, orCORRECT_ARGUMENT), andrecovery.messageis an English instruction you can show to developers.- For write failures, read
context.writeOutcome:not_sent,not_applied(explicitly rejected, not applied),partial, orunknown(the write result cannot be confirmed). Never retry a write automatically afterpartialorunknown. Verify the cart first. - Narrow with
window.xcotton.isXcottonError(error)instead ofinstanceofor message text.
const sdk = window.xcottonif (!sdk) throw new Error('[XC] sdk.js has not loaded yet')
try { await sdk.syncCart('gid://shopify/Cart/your-cart-id')} catch (error) { if (!sdk.isXcottonError(error)) throw error switch (error.code) { case 'SDK_NOT_INITIALIZED': await sdk.init() break case 'ADAPTER_RATE_LIMITED': // Wait for error.context.retryAfterMs before retrying break default: console.error(error.code, error.message, error.recovery.action) }}Common error messages
Section titled “Common error messages”| Error code or message | Cause | Fix |
|---|---|---|
[XC] SDK initialization failed (module=…, stage=…, shop=…): … | Initialization failed.module and stage identify the failing phase, and the text after the colon gives the reason | Use module, stage, causeCode, and the reason to locate the failure. Check shop, publicAccessToken, market values, and back-office configuration. Reload the page after the fix. If it persists, contact your account manager (AM) |
[XC] SDK not initialized. Call xcotton.init() before using SDK operations. | A cart or protection method ran before init() resolved | Await init() first |
[XC] syncCart requires a cartId before the first synchronization (…) | syncCart was called without an argument and no cart is bound | Call syncCart(cartId) for the first bind |
[XC] Cart operation requires a bound cart; obtain a cart and call syncCart(cartId) first. | A cart operation (such as addProtection) ran before the first synchronization | Get the cart and call syncCart(cartId). Do not call init again |
[XC] cart currency does not match the configured market (…) | The cart total currency differs from the configured currency | Pass the market currency and make sure the cart belongs to that market |
[XC] Cart session changed (…) | The bound cart switched while an operation was in flight | Re-run the operation against the newly bound cart |
[XC] Cart requests are rate limited during … (retryAfterMs=…) | Shopify or your API throttled cart requests. The SDK rejects calls during the cooldown (the adapter’s retry delay, or 30 seconds by default) | Retry after the cooldown expires |
[XC] pp.addToCart rejected an invalid argument (path=addMainProduct, …) | pp.addToCart was called without a main product callback | Pass a callback that writes only the main product and returns { cartId, mainLineId } |
[XC] pp.addToCart rejected an invalid argument (path=context, …) | pp.addToCart ran without an active product-page context, for example from a listing-page quick add | Call pp.loadPlans for the target variant first, or add the main product and call syncCart |
PP_WRITE_CONFIRMATION_FAILED | Purchase failed during the main-product, sync, or protection stage. The error alone does not prove that the main product was written | Follow context.stage, mainProduct, writeOutcome, and recovery. Do not re-add a confirmed main product, and verify the cart when its result is unknown |
PP_ALREADY_PROTECTED | The cart line already has a real protection line (pp.offerProtection, or pp.addProtection with a different plan) | Refresh the cart and hide the add-protection offer; call removeProtection first to switch plans |
PP_SELECTION_IN_PROGRESS | Another PP selection or purchase flow is still running | Wait for the active flow to settle before calling again |
[XC] addProtection rejected an invalid argument (path=mainLineId, …) | addProtection was called without mainLineId | Pass the target main product line’s CartLine.id |
[XC] addProtection rejected an invalid argument (path=selection, …) | selection was missing or does not belong to that main line | Use the original option from loadPlans / getCartQuotes. Do not re-add a main product that already succeeded |
[XC] removeProtection rejected an invalid argument (path=mainLineId, …) | removeProtection was called without mainLineId, or a protection line was passed | Pass the target main product line’s CartLine.id |
[XC] sp.resolveSP rejected an invalid argument (path=spMode, …): checkoutPlus has no switch intent; call addSpFee() or declineSpFee() instead | checkout+ (checkoutPlus) has no switch intent, so resolveSP() is invalid | Call addSpFee() or declineSpFee() from the two checkout buttons. Never infer the choice from the placeholder intent |
[XC] line.id: duplicate or missing cart line | A line targeted for update or removal is missing from the current cart or duplicated in one request | Verify that your adapter returns the complete cart |
[XC] updateLines: quantity or attributes required | A line update omitted both quantity and attributes | Check your adapter’s updateLines implementation |
[XC] Invalid MoneyV2 amount | A monetary amount is not a valid decimal string | Preserve Storefront amount strings unchanged |
[XC] …currencyCode: inconsistent currency (full path such as cart.cost.totalAmount.currencyCode) | An adapter money field uses a different currency from the cart total | Use one currency everywhere. Do not convert amounts |
Note: [XC] sdk.js has not loaded yet and [XC] SP is not ready are thrown by the defensive guards in this documentation’s example code, not by the SDK itself.