> For the complete documentation index, see [llms.txt](https://docs.captaintop.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.captaintop.com/integrate-captain-shipping-protection-with-a-shopify-headless-storefront.md).

# Integrate Captain Shipping Protection with a Shopify Headless Storefront

This guide explains how to add Captain Shipping Protection to a custom Shopify storefront.

It covers two integration options:

* **React storefronts:** use Captain's standard widget and controller hook.
* **Other storefronts:** use the framework-independent SDK and render your own UI.

Both options use the same Shopify cart and checkout flow.

### Before you start

Make sure:

* Captain Shipping Protection is configured for your Shopify store.
* The Captain Shipping Protection product is published to the Shopify Headless sales channel used by your storefront.
* Your storefront can read and update a Shopify cart.
* You can install npm packages.
* Your production storefront uses HTTPS.
* You know the active shopper country, locale, and currency.

> **Important:** Captain calculates which protection variant should be used, but your storefront is responsible for adding or removing Shopify cart lines.

#### Publish Shipping Protection to your Headless sales channel

This step is required. If the Captain Shipping Protection product is not available on the same Headless sales channel as your Storefront API token, Shopify cannot add the protection variant to the cart.

In Shopify Admin:

1. Go to **Products**.
2. Open the Captain Shipping Protection product.
3. Find **Publishing**, **Sales channels**, or **Sales channels and apps**.
4. Click **Manage**.
5. Enable the Headless sales channel used by your storefront.
6. Click **Done**, then save the product if Shopify asks you to save.

The channel name depends on your Shopify setup. Select the channel associated with the Storefront API token used by this headless storefront.

If you have multiple Headless channels, publishing the product to a different channel is not sufficient. The protection product must be available to the exact channel making the cart request.

You can verify the setup by querying the protection product or variant through the same Storefront API token. Shopify must return the product and allow its variant to be used as `merchandiseId` in `cartLinesAdd`.

### Choose an integration

#### Use the React package when

* Your storefront uses React 18 or later.
* You want Captain's standard widget.
* You want checked, loading, error, and cleanup state managed for you.

Install:

```sh
npm install captain-shipping-protection-react captain-shipping-protection-sdk
```

#### Use the Core SDK when

* Your storefront uses Vue, Svelte, Angular, Solid, or Vanilla JavaScript.
* You already have a shipping protection UI.
* You want full control over state and presentation.

Install:

```sh
npm install captain-shipping-protection-sdk
```

### How the integration works

Captain recommends adding protection only when checkout begins:

1. Read the current Shopify cart.
2. Convert it to Captain's cart format.
3. Ask Captain for the current price, variant, and eligibility.
4. Show the protection option when the cart is eligible.
5. Store the shopper's checked choice in your storefront.
6. If the cart already contains protection, remove that line and keep the shopper checked.
7. When checkout begins, request the latest protection information.
8. Add one unit of the latest variant when the shopper is checked.
9. Redirect immediately to the checkout URL returned by Shopify.

This approach avoids repeatedly changing the Shopify cart while the shopper edits products or quantities.

> **Important:** Toggling the widget records shopper intent only. It should not immediately add or remove a Shopify cart line.

### Prepare your Shopify cart data

Both packages require the same `SdkCartData` structure.

Shopify Storefront GraphQL returns GIDs and decimal money strings. Captain expects numeric Shopify IDs and integer minor-unit prices.

For USD:

* `"12.99"` from Shopify becomes `1299`.
* `"100.00"` from Shopify becomes `10000`.

Use a cart mapper like this:

```ts
import type {SdkCartData} from "captain-shipping-protection-sdk";

type StorefrontCart = {
  id: string;
  totalQuantity: number;
  cost: {
    subtotalAmount: {
      amount: string;
      currencyCode: string;
    };
  };
  lines: {
    nodes: Array<{
      id: string;
      quantity: number;
      cost: {
        totalAmount: {
          amount: string;
        };
      };
      merchandise: {
        id: string;
        sku?: string | null;
        title: string;
        product: {
          id: string;
          title: string;
        };
      };
    }>;
  };
};

function numericShopifyId(gid: string): number {
  const value = Number(gid.split("/").pop());

  if (!Number.isFinite(value)) {
    throw new Error(`Invalid Shopify GID: ${gid}`);
  }

  return value;
}

function toMinorUnits(amount: string): number {
  return Math.round(Number(amount) * 100);
}

export function toSdkCartData(cart: StorefrontCart): SdkCartData {
  return {
    token: cart.id,
    currency: cart.cost.subtotalAmount.currencyCode,
    total_price: toMinorUnits(cart.cost.subtotalAmount.amount),
    item_count: cart.totalQuantity,
    items: cart.lines.nodes.map((line) => ({
      id: numericShopifyId(line.merchandise.id),
      key: line.id,
      quantity: line.quantity,
      variant_id: numericShopifyId(line.merchandise.id),
      product_id: numericShopifyId(line.merchandise.product.id),
      final_line_price: toMinorUnits(line.cost.totalAmount.amount),
      title: line.merchandise.title,
      product_title: line.merchandise.product.title,
      sku: line.merchandise.sku ?? "",
    })),
  };
}
```

Create a new mapped cart whenever products, quantities, discounts, currency, or line prices change.

### Option A: React integration

The recommended React integration combines:

* `ShippingProtectionWidget` for the standard UI.
* `useShippingProtectionController` for Captain state and requests.

#### Add the controller

```tsx
import {
  ShippingProtectionWidget,
  useShippingProtectionController,
  type SdkCartData,
} from "captain-shipping-protection-react";

interface ShippingProtectionSectionProps {
  cart: SdkCartData;
  currency: string;
  findProtectionLineIds: (productId: string) => string[];
  removeCartLines: (lineIds: string[]) => Promise<void>;
  refreshCart: () => Promise<void>;
  addProtectionAndGetCheckoutUrl: (
    variantGid: string,
  ) => Promise<string>;
  getCheckoutUrl: () => Promise<string>;
}

function toVariantGid(variantId: string): string {
  return variantId.startsWith("gid://shopify/ProductVariant/")
    ? variantId
    : `gid://shopify/ProductVariant/${variantId}`;
}

export function ShippingProtectionSection({
  cart,
  currency,
  findProtectionLineIds,
  removeCartLines,
  refreshCart,
  addProtectionAndGetCheckoutUrl,
  getCheckoutUrl,
}: ShippingProtectionSectionProps) {
  const protection = useShippingProtectionController({
    shop: "example.myshopify.com",
    country: "US",
    locale: "en",
    currency,
    cart,

    removeProtection: async (info) => {
      const lineIds = findProtectionLineIds(info.productId);

      if (lineIds.length > 0) {
        await removeCartLines(lineIds);
        await refreshCart();
      }
    },
  });

  async function handleCheckout() {
    const latestInfo = await protection.prepareCheckout();

    const checkoutUrl = latestInfo
      ? await addProtectionAndGetCheckoutUrl(
          toVariantGid(latestInfo.variantsId),
        )
      : await getCheckoutUrl();

    window.location.assign(checkoutUrl);
  }

  return (
    <>
      {protection.visible && protection.info ? (
        <ShippingProtectionWidget
          checked={protection.checked}
          currency={currency}
          disabled={
            protection.togglePending ||
            protection.removePending
          }
          info={protection.info}
          onToggle={protection.toggle}
          setting={protection.setting}
        />
      ) : null}

      {protection.removeError ? (
        <div role="alert">
          <p>Could not clean up the existing protection line.</p>
          <button type="button" onClick={() => void protection.refresh()}>
            Retry
          </button>
        </div>
      ) : null}

      <button
        type="button"
        disabled={
          protection.initPending ||
          protection.infoPending ||
          protection.removePending
        }
        onClick={() => void handleCheckout()}
      >
        Checkout
      </button>
    </>
  );
}
```

Replace the adapter props with your Storefront API or Hydrogen cart implementation.

#### React state behavior

The controller handles these rules:

* Uses the merchant's default checked setting on the first eligible cart.
* Keeps the shopper's checked choice when the cart changes.
* Sets the widget to checked when an existing protection line is found.
* Calls `removeProtection()` to clean up that existing line.
* Prevents duplicate cleanup requests.
* Hides the widget when the cart is excluded.
* Recalculates the latest price and variant in `prepareCheckout()`.

#### React checkout behavior

`prepareCheckout()` returns:

* `ShippingProtectionInfo` when protection should be added.
* `null` when the shopper is unchecked or the cart is excluded.
* `null` when an existing protection line could not be safely cleaned up.

After receiving protection info:

1. Convert `variantsId` to a Shopify ProductVariant GID.
2. Add exactly one unit to the cart.
3. Use the checkout URL returned by that mutation.
4. Redirect immediately.

Do not remain on the cart page after adding protection. The cart-page controller removes existing protection so the next checkout attempt can calculate a fresh variant.

### Option B: Core SDK integration

The Core SDK returns plain data and works with any browser framework.

#### Initialize the SDK

```ts
import {
  shippingProtection,
  type ShippingProtectionInfo,
} from "captain-shipping-protection-sdk";

await shippingProtection.init({
  shop: "example.myshopify.com",
  country: "US",
  locale: "en",
  currency: "USD",
});
```

Call `init()` again if the active shop, country, locale, or currency changes.

#### Request protection information

```ts
const info = await shippingProtection.getInfo({
  cartData: toSdkCartData(shopifyCart),
});
```

The response includes:

```ts
interface ShippingProtectionInfo {
  productId: string;
  variantsId: string;
  price: string;
  includedProtection: boolean;
  isExcluded: boolean;
  excludedReason?: string;
}
```

#### Manage your UI state

The Core SDK does not store checked state. Use your framework's normal state management.

```ts
let checked = false;
let checkedInitialized = false;
let currentInfo: ShippingProtectionInfo | null = null;
let latestRequestId = 0;

async function syncProtection(shopifyCart: StorefrontCart) {
  const requestId = ++latestRequestId;
  const info = await shippingProtection.getInfo({
    cartData: toSdkCartData(shopifyCart),
  });

  // Ignore a response for an older cart.
  if (requestId !== latestRequestId) {
    return;
  }

  currentInfo = info;

  if (info.includedProtection) {
    checked = true;
    checkedInitialized = true;
    await removeProtectionLinesByProductId(info.productId);
    await refreshHostCart();
  } else if (!checkedInitialized) {
    checked =
      shippingProtection.setting?.tm_default_display_status === 1;
    checkedInitialized = true;
  } else if (info.isExcluded) {
    checked = false;
  }

  renderProtection({
    checked,
    hidden: info.isExcluded,
    info,
    setting: shippingProtection.setting,
  });
}

function onProtectionToggle(nextChecked: boolean) {
  checked = nextChecked;

  if (currentInfo) {
    renderProtection({
      checked,
      hidden: currentInfo.isExcluded,
      info: currentInfo,
      setting: shippingProtection.setting,
    });
  }
}
```

Implement `removeProtectionLinesByProductId`, `refreshHostCart`, and `renderProtection` in your application.

#### Prepare checkout with the Core SDK

```ts
function toVariantGid(variantId: string): string {
  return variantId.startsWith("gid://shopify/ProductVariant/")
    ? variantId
    : `gid://shopify/ProductVariant/${variantId}`;
}

async function prepareProtectionForCheckout() {
  let shopifyCart = await getCurrentShopifyCart();
  let info = await shippingProtection.getInfo({
    cartData: toSdkCartData(shopifyCart),
  });

  if (info.includedProtection) {
    await removeProtectionLinesByProductId(info.productId);
    shopifyCart = await getCurrentShopifyCart();
    info = await shippingProtection.getInfo({
      cartData: toSdkCartData(shopifyCart),
    });
  }

  if (!checked || info.isExcluded) {
    return null;
  }

  return {
    ...info,
    variantGid: toVariantGid(info.variantsId),
  };
}

async function checkout() {
  const protection = await prepareProtectionForCheckout();

  const checkoutUrl = protection
    ? await addProtectionAndGetCheckoutUrl(protection.variantGid)
    : await getCheckoutUrl();

  window.location.assign(checkoutUrl);
}
```

### Connect the Shopify Storefront Cart API

Both integration options require the host storefront to add and remove cart lines.

#### Add protection at checkout

Use Shopify's `cartLinesAdd` mutation:

```graphql
mutation AddShippingProtection(
  $cartId: ID!
  $lines: [CartLineInput!]!
) {
  cartLinesAdd(cartId: $cartId, lines: $lines) {
    cart {
      id
      checkoutUrl
    }
    userErrors {
      field
      message
    }
  }
}
```

Variables:

```json
{
  "cartId": "gid://shopify/Cart/your-cart-id",
  "lines": [
    {
      "merchandiseId": "gid://shopify/ProductVariant/123456789",
      "quantity": 1
    }
  ]
}
```

#### Remove existing protection

Find cart lines whose Shopify product ID matches `info.productId`, then remove their cart line IDs with `cartLinesRemove`:

```graphql
mutation RemoveShippingProtection(
  $cartId: ID!
  $lineIds: [ID!]!
) {
  cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
    cart {
      id
      checkoutUrl
    }
    userErrors {
      field
      message
    }
  }
}
```

Match by product ID, not only by variant ID. Captain may choose a different variant when the eligible cart total changes.

Always check Shopify `userErrors` before updating your local cart or redirecting.

### SSR storefronts

Run Captain SDK operations in the browser.

#### Next.js App Router

Place the integration in a Client Component:

```tsx
"use client";

import {
  ShippingProtectionWidget,
  useShippingProtectionController,
} from "captain-shipping-protection-react";
```

Pass serializable cart data from your Server Component to the Client Component.

#### Hydrogen, Remix, Nuxt, and SvelteKit

You can load the Shopify cart on the server, but initialize Captain after the component mounts or from browser-only cart code.

Do not call `init()`, `getInfo()`, or `prepareCheckout()` during the server render.

### Content Security Policy

Captain sends configuration and eligibility requests to:

```
https://insurance.captaintop.com
```

If your storefront has a Content Security Policy, add this origin to `connect-src`:

```
connect-src 'self' https://insurance.captaintop.com;
```

The React widget may display a merchant-configured external icon. If your CSP restricts images, add the icon's exact origin to `img-src`.

Captain does not require an external script, iframe, or font origin. Do not add broad permissions such as `*` or `'unsafe-inline'` solely for this integration.

### Understand common states

#### The widget is visible and checked

The shopper wants protection. Do not add it yet. Add the latest variant when checkout begins.

#### The widget is visible and unchecked

Continue checkout without adding protection.

#### `includedProtection` is `true`

The Shopify cart already contains Captain's protection product:

1. Keep the widget checked.
2. Remove all matching protection lines.
3. Refresh your host cart.
4. Keep checked enabled after the cart refresh.

#### `isExcluded` is `true`

Hide the widget and continue checkout without protection.

Common exclusion reasons include:

* `empty_cart`
* `invalid_cart`
* `only_shipping_protection`
* `excluded_variant`
* `check_display_hidden`

Treat exclusion reasons as extensible.

#### Captain requests fail

Hide or disable the protection UI and allow checkout without protection unless your business requirements say otherwise.

### Test your integration

Before deployment, test:

* An eligible cart with the widget checked.
* An eligible cart with the widget unchecked.
* An empty cart.
* A cart containing only protection.
* A cart containing an excluded variant.
* Quantity changes that select a different protection price.
* A cart loaded with an existing protection line.
* Failure while removing an existing protection line.
* Failure while adding protection.
* A stale response from an older cart request.
* A storefront with CSP enabled.
* Checkout redirect with and without protection.

### Troubleshooting

#### The widget does not appear

Check:

* The store has an active Captain configuration.
* SDK initialization completed.
* `info.isExcluded` and `info.excludedReason`.
* Cart product and variant IDs are valid numeric IDs.
* Browser requests are not blocked by CSP or CORS.

#### Toggling does not add a Shopify cart line

This is expected. The toggle stores intent only. Add the variant returned by the latest Captain request when checkout begins.

#### The protection price is incorrect

Verify:

* `total_price` uses integer minor units.
* Every `final_line_price` uses integer minor units.
* The latest cart is passed after products, quantities, or discounts change.
* Existing protection is included in mapped cart data so Captain can subtract it from the eligible total.

#### Shopify cannot add the returned variant

Convert a numeric variant ID to:

```
gid://shopify/ProductVariant/<variantsId>
```

Then confirm the Captain Shipping Protection product is published to the exact Headless sales channel associated with your Storefront API token.

When the product is not published to that channel, Shopify commonly returns an error similar to:

```
The merchandise with id gid://shopify/ProductVariant/... does not exist.
```

The variant may exist in Shopify Admin and still be unavailable to your headless storefront. Open the protection product's publishing settings, enable the correct Headless channel, save, and retry with the same Storefront token.

#### Duplicate protection lines appear

Before adding protection:

* Remove lines matching `info.productId`.
* Refresh the cart.
* Request Captain info again.
* Add exactly one unit of the returned variant.

#### Protection is removed immediately after checkout starts

Add protection and redirect immediately using the checkout URL returned by the same mutation. Do not publish the updated cart back to the visible cart page and remain there.

### Go-live checklist

* Captain Shipping Protection is configured for the store.
* The Shipping Protection product is published to the exact Headless sales channel used by the Storefront API token.
* The correct npm package or packages are installed.
* Shopify GIDs and money values are mapped correctly.
* The active country, locale, and currency are passed to Captain.
* The UI updates after every relevant cart change.
* Shopper toggle behavior changes intent only.
* Existing protection lines are removed by product ID.
* Checkout waits for protection cleanup to finish.
* The latest variant is requested immediately before checkout.
* Exactly one protection line is added.
* Shopify `userErrors` are handled.
* Checkout redirects using the updated cart URL.
* CSP allows `https://insurance.captaintop.com`.
* Error paths allow the shopper to continue checkout safely.

### Package reference

For complete API and type details:

* React package: `captain-shipping-protection-react`
* Framework-independent SDK: `captain-shipping-protection-sdk`
