Skip to content

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:

  1. Product page pricing: call loadPlans(variantId). The SDK fetches quotes for the variant and renders available plans in the PP panel.
  2. Customer clicks Add to cart: pass a callback that only writes the main product.variantId no longer has to be repeated.
  3. The SDK resolves the selection: from the product context established by loadPlans (or an explicit context). 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.
  4. Write the main product: the SDK calls addMainProduct(attributes, context) exactly once.attributes are the main line attributes your storefront writes as-is.
  5. Sync and add protection: the SDK synchronizes the cart with the returned cartId, writes the protection line against the returned mainLineId, and resolves { cartId, mainLineId, protection }.
  • Your account manager (AM) has enabled PP (window.xcotton.pp is undefined when disabled).
  • You have completed the Quick start. Its startup component renders the ppWidgetSelector container on product pages and calls loadPlans whenever the selected variant changes.
  • window.xcotton.pp is ready as soon as init() resolves. Pricing and plan selection do not require a cart.
  • Your storefront provides the following functions (imported from lib/cart.ts in 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, writes attributes as its line attributes, and returns { cartId, mainLineId }. It must use the passed arguments instead of reading the page’s current variant at write time. The addToCart callback only adds the variant and quantity in your storefront wrapper.
    • showProtectionError(error): shows a partial-success or failure message.
  • 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 }: options is a snapshot of selections you can submit directly, and context is this quote’s product handle (pass it to canBePopup / getSelectedPlan / setSelectedPlan / showModal when 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 to null.
  • 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' },
})
  1. Fix the product at click time: read variantId and the quantity at the top of the handler, so later page changes cannot affect this add-to-cart.
  2. Call pp.addToCart() once: pass a callback that only writes the main product. context is optional (the current product context established by loadPlans). Pass the handle returned by loadPlans to 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.
  3. 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.
  4. Handle the result: a successful call resolves protection: 'added' or 'skipped', and both carry cartId / 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 is PP_WRITE_CONFIRMATION_FAILED with stage / mainProduct / protection / writeOutcome in 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.
  5. Failures: if the main-product callback throws, the call also rejects, but context.mainProduct reports the main-product result, usually unknown. 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.

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.

components/add-to-cart-button.tsx
'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>
)
}
ActionExpected result
Open a product page before any cart existsThe PP panel fetches quotes and displays available plans
Switch variants rapidlyThe panel shows only the last variant’s plans. Old selections are voided
Navigate away from the product pageThe panel clears and any open plan modal closes
Select a plan and click Add to cartThe 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 disabledprotection is 'skipped'. The main product is added without protection for this item
Click Add to cart with no plan selected and the automatic modal enabledThe plan modal opens first. Cancelling it resolves protection: 'skipped' and still adds the main product
Click Add to cart with PP disabledwindow.xcotton.pp does not exist. Ordinary add-to-cart does not go through the PP entry
Simulate a protection write failureThe call rejects PP_WRITE_CONFIRMATION_FAILED. The main product is written only once and stays in the cart
Add to cart before any cart existsThe 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 cartAfter 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 lineThe main product line remains in the cart
SymptomCause and fix
The main product is added twicepp.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 busypp.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 lineThe 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 inaccurateVerify 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 mismatchThe cart or variant details differ from the initialized currency. Align the market and synchronize again. Do not repeat a successful main-product write