Add product protection
Product Protection (PP) is priced per item. Integration spans two stages: on the product page, you load plan quotes and let the customer choose a plan (no cart required). When the customer clicks “Add to cart”, you call pp.addToCart() and the SDK resolves the selection, writes the main product, synchronizes the cart, and writes the protection line.
The end-to-end flow:
- Product page pricing: call
loadPlans(variantId). The SDK fetches quotes for the variant and renders available plans in the PP panel. - Customer clicks Add to cart: pass a callback that only writes the main product.
variantIdno longer has to be repeated. - The SDK resolves the selection: from the product context established by
loadPlans(or an explicitcontext). When no plan is selected and the automatic modal is enabled, the SDK opens the plan modal first, and cancelling still adds the main product. - Write the main product: the SDK calls
addMainProduct(attributes, context)exactly once.attributesare the main line attributes your storefront writes as-is. - Sync and add protection: the SDK synchronizes the cart with the returned
cartId, writes the protection line against the returnedmainLineId, and resolves{ cartId, mainLineId, protection }.
Before you start
Section titled “Before you start”- Your account manager (AM) has enabled PP (
window.xcotton.ppisundefinedwhen disabled). - You have completed the Quick start. Its startup component renders the
ppWidgetSelectorcontainer on product pages and callsloadPlanswhenever the selected variant changes. window.xcotton.ppis ready as soon asinit()resolves. Pricing and plan selection do not require a cart.- Your storefront provides the following functions (imported from
lib/cart.tsin the example):getSelectedVariantId(): returns the target variant ID at click time.getSelectedQuantity(): returns the quantity for this add-to-cart.addMainProduct(variantId, quantity, attributes): writes the main product for the passed variant and quantity, writesattributesas its line attributes, and returns{ cartId, mainLineId }. It must use the passed arguments instead of reading the page’s current variant at write time. TheaddToCartcallback only adds the variant and quantity in your storefront wrapper.showProtectionError(error): shows a partial-success or failure message.
Pricing
Section titled “Pricing”- When the selected variant changes: call
loadPlans(variantId)with the new variant ID (string or number). The SDK queries the product and price for the market configured at initialization. It resolves{ context, options }:optionsis a snapshot of selections you can submit directly, andcontextis this quote’s product handle (pass it tocanBePopup/getSelectedPlan/setSelectedPlan/showModalwhen the product must be pinned, and omit it on a single-product page). - When leaving the product page: call
loadPlans(null). The SDK cancels any in-flight quote request, closes any open plan modal, clears the PP panel and the current selection, and resolves tonull. - When switching variants rapidly: only the latest quote request takes effect. A superseded call rejects with
PP_CONTEXT_CHANGED. Both the old selection and its context handle become invalid, and A → B → A does not revive the old handle. - When the quote fails: a failed request or an unparseable response rejects with a specific error. When the backend returns a nonzero
errCode, the current implementation treats the product as having no plans and resolves to{ context, options: [] }. Do not read that as a network failure. A successful quote with no plans resolves to the same shape.
If your storefront already has the variant details for the active market, you can pass them as the second argument to skip the SDK’s Storefront lookup:
await window.xcotton?.pp?.loadPlans(variantId, { product: { id: 'gid://shopify/Product/1234567890' }, title: 'Default Title', sku: 'SKU-001', price: { amount: '29.99', currencyCode: 'USD' },})Add to cart
Section titled “Add to cart”- Fix the product at click time: read
variantIdand the quantity at the top of the handler, so later page changes cannot affect this add-to-cart. - Call
pp.addToCart()once: pass a callback that only writes the main product.contextis optional (the current product context established byloadPlans). Pass the handle returned byloadPlansto pin it explicitly. Do not call the SDK from inside the callback, do not read the current page variant there, and do not reset the PDP quote. - Pass the line attributes through: the callback receives
attributes(AttributeInput[]). Write them as the main product’s line attributes unchanged. They keep protected and unprotected items of the same variant on separate cart lines instead of letting Shopify merge them. - Handle the result: a successful call resolves
protection: 'added'or'skipped', and both carrycartId/mainLineId.'skipped': the customer selected no plan and the automatic modal is disabled, or the customer cancelled the modal. The main product was added and synchronized. Nothing else is needed.- Failures reject with
XcottonError. When the main product was written but synchronization or the protection write failed, the error isPP_WRITE_CONFIRMATION_FAILEDwithstage/mainProduct/protection/writeOutcomein its context. Do not add the main product again: refresh the cart to confirm whether the main line exists, then show the partial success or let the customer add protection from the cart.
- Failures: if the main-product callback throws, the call also rejects, but
context.mainProductreports the main-product result, usuallyunknown. The callback runs at most once, and the SDK neither replays it nor rolls back.
The returned mainLineId is the main product line ID, not the protection line ID. The SDK maintains the protection line for that main line. Call getRelations() to read the pairing, and never pass a protection line ID as mainLineId to a cart line operation.
You no longer read the selection yourself or call addProtection() separately, check selection.mainVariantId, synchronize manually, or re-quote. Those steps happen inside pp.addToCart(). Use the primitives directly only when you build your own add-to-cart UI: canBePopup() checks the automatic modal condition, getSelectedPlan() reads the current selection, and setSelectedPlan() submits or clears it. See the SDK reference.
Manual chain (equivalent)
Section titled “Manual chain (equivalent)”Without the convenience wrapper, compose the atomic methods in the same order:
const selected = await window.xcotton.pp.getSelectedPlan()const attributes = selected?.mainLineAttributes ?? []const { cartId, mainLineId } = await addMainProduct(variantId, quantity, attributes)await window.xcotton.syncCart(cartId)if (selected) await window.xcotton.pp.addProtection({ mainLineId, selection: selected })Use canBePopup() followed by showModal() when you need the configured automatic modal. Your storefront handles the main-product, sync, and protection failure stages itself. Both paths share the same main-line, variant, and plan validation. The convenience wrapper holds the cart operation for the whole flow, while each step of the manual chain acquires it separately, so do not insert other cart writes between the steps.
Full example
Section titled “Full example”'use client'
import { xcottonInit } from '@/lib/xcotton'import { getSelectedVariantId, getSelectedQuantity, addMainProduct, showProtectionError,} from '@/lib/cart'async function onAddToCartClick() { // Fix the variant and quantity at click time; the page may change them while the quote is pending. const variantId = getSelectedVariantId() const quantity = getSelectedQuantity() await xcottonInit() const pp = window.xcotton?.pp if (!pp) throw new Error('[XC] Product Protection is not enabled') try { const result = await pp.addToCart({ addMainProduct: (attributes) => addMainProduct(variantId, quantity, attributes), }) return result } catch (error) { // Partial success and failures stay with the storefront; never add the main product again. showProtectionError(error) }}
export function AddToCartButton() { return ( <button type="button" onClick={() => void onAddToCartClick().catch(console.error)} > Add to cart </button> )}import { xcottonInit } from '../lib/xcotton'import { getSelectedVariantId, getSelectedQuantity, addMainProduct, showProtectionError,} from '../lib/cart'async function onAddToCartClick() { // Fix the variant and quantity at click time; the page may change them while the quote is pending. const variantId = getSelectedVariantId() const quantity = getSelectedQuantity() await xcottonInit() const pp = window.xcotton?.pp if (!pp) throw new Error('[XC] Product Protection is not enabled') try { const result = await pp.addToCart({ addMainProduct: (attributes) => addMainProduct(variantId, quantity, attributes), }) return result } catch (error) { // Partial success and failures stay with the storefront; never add the main product again. showProtectionError(error) }}
export function AddToCartButton() { return ( <button type="button" onClick={() => void onAddToCartClick().catch(console.error)} > Add to cart </button> )}<script setup lang="ts">import { xcottonInit } from '~/lib/xcotton'import { getSelectedVariantId, getSelectedQuantity, addMainProduct, showProtectionError,} from '~/lib/cart'async function onAddToCartClick() { // Fix the variant and quantity at click time; the page may change them while the quote is pending. const variantId = getSelectedVariantId() const quantity = getSelectedQuantity() await xcottonInit() const pp = window.xcotton?.pp if (!pp) throw new Error('[XC] Product Protection is not enabled') try { const result = await pp.addToCart({ addMainProduct: (attributes) => addMainProduct(variantId, quantity, attributes), }) return result } catch (error) { // Partial success and failures stay with the storefront; never add the main product again. showProtectionError(error) }}
function onClick() { void onAddToCartClick().catch(console.error)}</script>
<template> <button type="button" @click="onClick">Add to cart</button></template>import { xcottonInit } from '../lib/xcotton'import { getSelectedVariantId, getSelectedQuantity, addMainProduct, showProtectionError,} from '../lib/cart'async function onAddToCartClick() { // Fix the variant and quantity at click time; the page may change them while the quote is pending. const variantId = getSelectedVariantId() const quantity = getSelectedQuantity() await xcottonInit() const pp = window.xcotton?.pp if (!pp) throw new Error('[XC] Product Protection is not enabled') try { const result = await pp.addToCart({ addMainProduct: (attributes) => addMainProduct(variantId, quantity, attributes), }) return result } catch (error) { // Partial success and failures stay with the storefront; never add the main product again. showProtectionError(error) }}
export function AddToCartButton() { return ( <button type="button" onClick={() => void onAddToCartClick().catch(console.error)} > Add to cart </button> )}<script setup lang="ts">import { xcottonInit } from '../lib/xcotton'import { getSelectedVariantId, getSelectedQuantity, addMainProduct, showProtectionError,} from '../lib/cart'async function onAddToCartClick() { // Fix the variant and quantity at click time; the page may change them while the quote is pending. const variantId = getSelectedVariantId() const quantity = getSelectedQuantity() await xcottonInit() const pp = window.xcotton?.pp if (!pp) throw new Error('[XC] Product Protection is not enabled') try { const result = await pp.addToCart({ addMainProduct: (attributes) => addMainProduct(variantId, quantity, attributes), }) return result } catch (error) { // Partial success and failures stay with the storefront; never add the main product again. showProtectionError(error) }}
function onClick() { void onAddToCartClick().catch(console.error)}</script>
<template> <button type="button" @click="onClick">Add to cart</button></template><script lang="ts"> import { xcottonInit } from '$lib/xcotton' import { getSelectedVariantId, getSelectedQuantity, addMainProduct, showProtectionError, } from '$lib/cart' async function onAddToCartClick() { // Fix the variant and quantity at click time; the page may change them while the quote is pending. const variantId = getSelectedVariantId() const quantity = getSelectedQuantity() await xcottonInit() const pp = window.xcotton?.pp if (!pp) throw new Error('[XC] Product Protection is not enabled') try { const result = await pp.addToCart({ addMainProduct: (attributes) => addMainProduct(variantId, quantity, attributes), }) return result } catch (error) { // Partial success and failures stay with the storefront; never add the main product again. showProtectionError(error) } }
function onClick() { void onAddToCartClick().catch(console.error) }</script>
<button type="button" onclick={onClick}>Add to cart</button>import { xcottonInit } from '../lib/xcotton'import { getSelectedVariantId, getSelectedQuantity, addMainProduct, showProtectionError,} from '../lib/cart'async function onAddToCartClick() { // Fix the variant and quantity at click time; the page may change them while the quote is pending. const variantId = getSelectedVariantId() const quantity = getSelectedQuantity() await xcottonInit() const pp = window.xcotton?.pp if (!pp) throw new Error('[XC] Product Protection is not enabled') try { const result = await pp.addToCart({ addMainProduct: (attributes) => addMainProduct(variantId, quantity, attributes), }) return result } catch (error) { // Partial success and failures stay with the storefront; never add the main product again. showProtectionError(error) }}
export function AddToCartButton() { return ( <button type="button" onClick={() => void onAddToCartClick().catch(console.error)} > Add to cart </button> )}Verify and troubleshoot
Section titled “Verify and troubleshoot”| Action | Expected result |
|---|---|
| Open a product page before any cart exists | The PP panel fetches quotes and displays available plans |
| Switch variants rapidly | The panel shows only the last variant’s plans. Old selections are voided |
| Navigate away from the product page | The panel clears and any open plan modal closes |
| Select a plan and click Add to cart | The main product is written exactly once. Both the main product line and its linked protection line exist, and the protection quantity equals the total quantity of protected main lines with the same variant and plan |
| Click Add to cart with no plan selected and the automatic modal disabled | protection is 'skipped'. The main product is added without protection for this item |
| Click Add to cart with no plan selected and the automatic modal enabled | The plan modal opens first. Cancelling it resolves protection: 'skipped' and still adds the main product |
| Click Add to cart with PP disabled | window.xcotton.pp does not exist. Ordinary add-to-cart does not go through the PP entry |
| Simulate a protection write failure | The call rejects PP_WRITE_CONFIRMATION_FAILED. The main product is written only once and stays in the cart |
| Add to cart before any cart exists | The callback creates or resolves a cart and returns its identity. The SDK synchronizes it and writes protection as needed |
| Remove the main product line and synchronize the cart | After syncCart, the protection quantity is recomputed as the sum of the remaining main lines in the group (same variant and _PlanID). The protection line is removed when none remain |
| Remove the protection line | The main product line remains in the cart |
| Symptom | Cause and fix |
|---|---|
| The main product is added twice | pp.addToCart was called again after a partial failure, or the callback also wrote the main product. On failure, only show a message |
| An SDK write called inside the main-product callback reports busy | pp.addToCart() already owns the purchase operation. Calling syncCart inside the callback rejects with OPERATION_IN_PROGRESS. Other PP selection or purchase entries reject with PP_SELECTION_IN_PROGRESS. Write only the main product and return its identity. The SDK handles synchronization and protection afterward |
| Protected and unprotected items merge into a single line | The callback ignored the attributes it received, or replaced them with other attributes |
protection is always 'skipped' | No plan was selected and the automatic modal is disabled. Use canBePopup() / getSelectedPlan() to inspect the current quote and selection |
| Plan prices are inaccurate | Verify that variantDetail.price.amount is a decimal string and currencyCode matches the active market. Omit variantDetail if unsure |
| A quote rejects or a partial-success error reports a currency mismatch | The cart or variant details differ from the initialized currency. Align the market and synchronize again. Do not repeat a successful main-product write |