> ## Documentation Index
> Fetch the complete documentation index at: https://docs.loopreturns.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Checkout+ API Integration Guide

<Warning>
  Checkout+ is not enabled by default. To get started, reach out to [support@loopreturns.com](mailto:support@loopreturns.com) to enable it for your account.
</Warning>

## Overview

**Checkout+** is Loop's order protection product for stores that integrate via the Checkout+ API at checkout (rather than native Loop checkout extensions). It lets you offer order protection to your customers at checkout — covering returns, shipping issues, or both — via a single API call.

Every integration starts the same way: your storefront sends the customer's cart to `POST /v1/analyze`, receives eligibility and pricing, and presents the offer. What differs is **how the accepted offer reaches Loop**, and that depends on how Loop already ingests your orders:

| Integration model | Platforms                                                       | How the offer reaches Loop                                                                                                                                                                  |
| ----------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Order API**     | WooCommerce, BigCommerce, non-Shopify headless / custom         | Apply the charge as a native fee, then push the completed order to the [Loop Order API](/api-reference/latest/orders/create-order) with the coverage mode and session id attached.          |
| **Shopify**       | Shopify storefronts not using Loop's Shopify checkout extension | Add Loop's coverage product to the cart and set a cart attribute. Loop ingests Shopify orders directly, so there is **no Order API call**. See [Shopify Integration](#shopify-integration). |

The bulk of this guide — authentication, the `/v1/analyze` request and response, eligibility, UI guidelines, and error handling — applies to **both** models. The two paths diverge only in how you apply the charge and report the acceptance, covered in [Integration Steps](#integration-steps) (Order API) and [Shopify Integration](#shopify-integration).

<Tip>
  **When is the Checkout+ API Integration the right fit?**

  Checkout+ API integration is designed for merchants on WooCommerce, BigCommerce, headless / custom platforms, or Shopify storefronts that **don't** use Loop's Shopify checkout extension, who want to offer order protection at checkout. If your store already uses Loop's native checkout extensions for order protection, you typically don't need this API integration.
</Tip>

***

## How It Works

```mermaid theme={null}
sequenceDiagram
    participant Store as Your Storefront
    participant CpApi as Checkout+ API
    participant Platform as Commerce Platform
    participant OrderApi as Loop Order API

    Store->>CpApi: POST /v1/analyze (cart, platform, region)
    CpApi-->>Store: sessionId, eligible, mode[], chargeInstructions

    alt Non-Shopify — Order API path
        alt Customer accepts
            Store->>Platform: Apply fee via any native mechanism
            Platform-->>Store: Order created with fee line
            Store->>OrderApi: POST /orders with fees[].accepted_offer_mode = mode<br/>and metadata[session_id] = sessionId
        else Customer declines
            Store->>Platform: Complete order with no fee
        end
    else Shopify — direct ingestion
        alt Customer accepts
            Store->>Platform: Add coverage product to cart with line item properties (_mode)
            Store->>Platform: Set cart attributes (__loop_session_id)
            Store->>Platform: Customer checks out
        else Customer declines
            Store->>Platform: Remove coverage product from cart if added
            Store->>Platform: Set cart attributes (__loop_session_id)
            Store->>Platform: Customer checks out
        end
        Note over Platform: Loop ingests the order directly — no Order API call.<br/>See Shopify Integration.
    end
```

<Info>
  **Shopify integrators:** follow steps 1–2 below, then skip to [Shopify Integration](#shopify-integration).
</Info>

1. **Analyze the cart** — Your integration sends the customer's cart to `POST /v1/analyze`. The API evaluates eligibility rules, calculates the protection price, and returns a `sessionId`, the resolved coverage `mode[]`, and `chargeInstructions`.
2. **Present the offer** — If `eligible` is `true`, display the protection offer to the customer using the price in `chargeInstructions.amount` and copy that matches the modes in `mode[]`.
3. **Apply the charge (non-Shopify platforms)** — If the customer accepts, apply the fee to the cart using whatever native fee mechanism your platform provides. Loop does not prescribe how the fee is stored on the platform.
4. **Report the fee and session to Loop (non-Shopify platforms)** — Once the order is created, push it to the [Loop Order API](/api-reference/latest/orders/create-order) with both:
   * A `fees[]` entry for the protection charge, with `fees[].accepted_offer_mode` set to the `mode` array returned by `/v1/analyze`, verbatim. Loop uses this to recognize the charge as a Checkout+ acceptance and activate coverage.
   * A `metadata` entry with `key: "session_id"` and `value` set to the `sessionId` returned by `/v1/analyze`.

***

## Base URL

All Checkout+ endpoints are served from:

```
https://api.loopreturns.com/storefront/v1
```

Note that this is distinct from the Loop Order API base (`https://api.loopreturns.com/api/v1`) — both are used by a complete Checkout+ integration. The cURL and code samples in this guide always show fully-qualified URLs so you can tell which service each call targets.

***

## Authentication

Checkout+ uses the same authentication as the rest of Loop's APIs. Send your merchant API key in the `X-Authorization` header — see [Authentication](/api-reference/authentication) for how to create and manage keys, the full header contract, and the 401 response shape.

`/v1/analyze` requires the **Cart** scope. A key without `Cart` — including the key that authenticates your Order API integration — will be rejected with `401 unauthorized`. Generate a dedicated Checkout+ key from **Returns Management → Tools & integrations → Developer tools** in the Loop Admin and select the **Cart** scope.

<Warning>
  **Use a Cart-only API key for Checkout+ — never re-use a broadly-scoped key.** `/v1/analyze` is browser-callable (see [CORS](#cors)), so any storefront that calls it from client-side JavaScript exposes its API key to every shopper who opens the network tab. A key scoped to **Cart only** keeps the blast radius minimal: a leaked credential can call `/v1/analyze` and nothing else — it cannot read orders, modify returns, or touch any other Loop resource. Re-using a key that also carries `orders`, `returns`, or any other scope turns a routine browser leak into a full-account incident.
</Warning>

***

## CORS

The Checkout+ API is browser-callable — every response (success or error) carries the following CORS headers:

| Header                         | Value                                                                      |
| ------------------------------ | -------------------------------------------------------------------------- |
| `Access-Control-Allow-Origin`  | The request's `Origin` (echoed verbatim), or `*` when no `Origin` was sent |
| `Access-Control-Allow-Methods` | `GET, POST, OPTIONS`                                                       |
| `Access-Control-Allow-Headers` | `Content-Type, Authorization, X-Authorization`                             |
| `Access-Control-Max-Age`       | `86400` (preflight cached for 24 hours)                                    |

Browsers may issue an `OPTIONS` preflight before the actual request whenever you send `X-Authorization` or a non-simple `Content-Type`. Loop responds to the preflight with the headers above and a `204 No Content`, no merchant API key required for the `OPTIONS` itself.

<Warning>
  Calling `/v1/analyze` directly from the browser exposes your merchant API key to anyone who inspects the network tab. Keep the credential server-side and proxy through your own backend whenever possible.
</Warning>

***

## Integration Steps

<Note>
  All integrations follow steps 1 and 2. **Shopify integrators:** skip steps 3 and 4 — see [Shopify Integration](#shopify-integration).
</Note>

<Steps>
  <Step title="Call the Analyze endpoint" noAnchor="true">
    Send the customer's cart data to `POST /v1/analyze` whenever the cart changes or the customer reaches checkout.

    At minimum, include:

    * `platform` — `woocommerce`, `bigcommerce`, `custom`, or another string identifying your storefront.
    * `region` — the geographic region for eligibility evaluation. Either an ISO 3166-1 alpha-2 country (e.g. `"US"`), an ISO 3166-2 region (`"US-OH"`), or a structured `{ country, subdivision? }` object.
    * `cart` — with `lines`, `currencyCode`, `subtotalAmount`, `totalDiscountAmount`, `discountedSubtotalAmount`, `totalTaxAmount`, `totalAmount`, and `itemCount`.
    * `deliveryGroups` — **required only when your merchant configuration includes `shippingProtection`.** See [Shipping Protection and Delivery Methods](#shipping-protection-and-delivery-methods).

    Each cart line requires `id`, `productId`, `quantity`, `totalPrice`, `originalTotalPrice`, and `title`. `unitPrice` is optional.

    Optionally include a top-level `cartId` (a stable identifier from your platform) or `sessionId` (returned by a previous `/analyze` call) to re-use the existing offer session instead of creating a new one. See [Idempotency](#idempotency).

    <CodeGroup>
      ```bash cURL theme={null}
      curl -X POST https://api.loopreturns.com/storefront/v1/analyze \
        -H "Content-Type: application/json" \
        -H "X-Authorization: YOUR_MERCHANT_API_KEY" \
        -d '{
          "platform": "woocommerce",
          "region": "US",
          "cartId": "cart_8c2f9e7d1a4b",
          "cart": {
            "lines": [
              {
                "id": "line_001",
                "productId": "prod_67890",
                "quantity": 2,
                "unitPrice": { "amount": 2500, "currencyCode": "USD" },
                "totalPrice": { "amount": 5000, "currencyCode": "USD" },
                "originalTotalPrice": { "amount": 5000, "currencyCode": "USD" },
                "title": "Classic T-Shirt"
              }
            ],
            "itemCount": 2,
            "subtotalAmount": 5000,
            "totalDiscountAmount": 0,
            "discountedSubtotalAmount": 5000,
            "totalTaxAmount": 400,
            "totalAmount": 5400,
            "currencyCode": "USD"
          }
        }'
      ```

      ```python Python theme={null}
      import requests

      response = requests.post(
          "https://api.loopreturns.com/storefront/v1/analyze",
          headers={
              "Content-Type": "application/json",
              "X-Authorization": "YOUR_MERCHANT_API_KEY",
          },
          json={
              "platform": "woocommerce",
              "region": "US",
              "cartId": "cart_8c2f9e7d1a4b",
              "cart": {
                  "lines": [
                      {
                          "id": "line_001",
                          "productId": "prod_67890",
                          "quantity": 2,
                          "unitPrice": {"amount": 2500, "currencyCode": "USD"},
                          "totalPrice": {"amount": 5000, "currencyCode": "USD"},
                          "originalTotalPrice": {"amount": 5000, "currencyCode": "USD"},
                          "title": "Classic T-Shirt",
                      }
                  ],
                  "itemCount": 2,
                  "subtotalAmount": 5000,
                  "totalDiscountAmount": 0,
                  "discountedSubtotalAmount": 5000,
                  "totalTaxAmount": 400,
                  "totalAmount": 5400,
                  "currencyCode": "USD",
              },
          },
      )

      data = response.json()
      ```

      ```javascript JavaScript theme={null}
      const response = await fetch("https://api.loopreturns.com/storefront/v1/analyze", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "X-Authorization": "YOUR_MERCHANT_API_KEY",
        },
        body: JSON.stringify({
          platform: "woocommerce",
          region: "US",
          cartId: "cart_8c2f9e7d1a4b",
          cart: {
            lines: [
              {
                id: "line_001",
                productId: "prod_67890",
                quantity: 2,
                unitPrice: { amount: 2500, currencyCode: "USD" },
                totalPrice: { amount: 5000, currencyCode: "USD" },
                originalTotalPrice: { amount: 5000, currencyCode: "USD" },
                title: "Classic T-Shirt",
              },
            ],
            itemCount: 2,
            subtotalAmount: 5000,
            totalDiscountAmount: 0,
            discountedSubtotalAmount: 5000,
            totalTaxAmount: 400,
            totalAmount: 5400,
            currencyCode: "USD",
          },
        }),
      });

      const data = await response.json();
      ```

      ```php PHP theme={null}
      <?php

      $url = "https://api.loopreturns.com/storefront/v1/analyze";
      $payload = json_encode([
          "platform" => "woocommerce",
          "region" => "US",
          "cartId" => "cart_8c2f9e7d1a4b",
          "cart" => [
              "lines" => [
                  [
                      "id" => "line_001",
                      "productId" => "prod_67890",
                      "quantity" => 2,
                      "unitPrice" => ["amount" => 2500, "currencyCode" => "USD"],
                      "totalPrice" => ["amount" => 5000, "currencyCode" => "USD"],
                      "originalTotalPrice" => ["amount" => 5000, "currencyCode" => "USD"],
                      "title" => "Classic T-Shirt",
                  ],
              ],
              "itemCount" => 2,
              "subtotalAmount" => 5000,
              "totalDiscountAmount" => 0,
              "discountedSubtotalAmount" => 5000,
              "totalTaxAmount" => 400,
              "totalAmount" => 5400,
              "currencyCode" => "USD",
          ],
      ]);

      $options = [
          "http" => [
              "header" => "Content-Type: application/json\r\nX-Authorization: YOUR_MERCHANT_API_KEY\r\n",
              "method" => "POST",
              "content" => $payload,
          ],
      ];

      $context = stream_context_create($options);
      $result = file_get_contents($url, false, $context);
      $data = json_decode($result, true);
      ```
    </CodeGroup>
  </Step>

  <Step title="Handle the response" noAnchor="true">
    The response tells you whether the cart is eligible and, if so, what coverage was offered and how to apply the charge.

    ```json theme={null}
    {
      "sessionId": "lop_a1b2c3d4e5f6g7h8i9j0",
      "eligible": true,
      "reason": "eligible",
      "mode": ["returnCoverage", "shippingProtection"],
      "chargeInstructions": {
        "amount": 498,
        "currencyCode": "USD",
        "method": "fee"
      },
      "metadata": {
        "priceBreakdown": [
          { "type": "base", "amount": 298, "label": "Return coverage" },
          { "type": "base", "amount": 200, "label": "Shipping protection" }
        ],
        "pricingSubtotalUsed": 5000,
        "experimentCohort": null,
        "sessionCreated": true,
        "analysisCount": 1
      }
    }
    ```

    | Field                       | What it tells you                                                                                                                                                                                                                                                             |
    | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `sessionId`                 | Stable identifier for this offer session. Persist it alongside your cart so subsequent `/analyze` calls can re-use the same session.                                                                                                                                          |
    | `eligible`                  | Whether to show the protection offer.                                                                                                                                                                                                                                         |
    | `reason`                    | Why the cart is or isn't eligible (useful for debugging).                                                                                                                                                                                                                     |
    | `mode`                      | A subset of `["returnCoverage", "shippingProtection"]` describing what coverage was offered. Present only when `eligible` is `true` — omitted when the cart is ineligible. Drives the customer-facing copy and is reported back on the order as `fees[].accepted_offer_mode`. |
    | `chargeInstructions.amount` | The fee to charge, in minor currency units.                                                                                                                                                                                                                                   |
    | `chargeInstructions.method` | `fee` for Checkout+ API integrations. (`product_line_item` is reserved for Loop's Shopify integration.)                                                                                                                                                                       |
    | `metadata.priceBreakdown`   | Component-by-component breakdown of how the protection price was calculated (one entry per coverage mode). Useful for debug surfaces and support tooling.                                                                                                                     |
    | `metadata.sessionCreated`   | `true` if this call created a new session, `false` if it re-analyzed an existing one.                                                                                                                                                                                         |
    | `metadata.analysisCount`    | How many analyses this session has had, including the current one.                                                                                                                                                                                                            |

    If `eligible` is `false`, `chargeInstructions` is `null` — do not show the offer.
  </Step>

  <Step title="Apply the charge" noAnchor="true">
    <Note>
      **Shopify integrators:** skip to [Shopify Integration](#shopify-integration).
    </Note>

    If the customer accepts protection, add a fee to the order using whatever native mechanism your platform provides. Loop does not prescribe the storage format — only the amount and currency need to match `chargeInstructions`.

    <Tip>
      Suggested mechanisms (non-normative):

      * **WooCommerce** — `WC_Cart::add_fee()` inside `woocommerce_cart_calculate_fees`.
      * **BigCommerce** — the [Checkout Fees API](https://developer.bigcommerce.com/docs/rest-management/checkouts/checkout-fees) (or an order-level fee on the Orders API).
      * **Headless / custom** — any order-level fee or surcharge supported by your commerce platform.
    </Tip>

    Convert `chargeInstructions.amount` from minor currency units (e.g. `498` → `4.98`) when your platform expects major units.
  </Step>

  <Step title="Report the fee and session to Loop" noAnchor="true">
    Once the order is created on your platform, push it to the [Loop Order API](/api-reference/latest/orders/create-order) (or upsert it via your existing order-sync pipeline). The order payload must include **both**:

    * A `fees[]` entry for the protection charge, with `accepted_offer_mode` set to the `mode` array returned by `/v1/analyze`, **verbatim** — do not transform, sort, or stringify the array.
    * A `metadata` entry with `key: "session_id"` and `value` set to the `sessionId` returned by `/v1/analyze` (e.g. `lop_a1b2c3d4e5f6g7h8i9j0`).

    The fragment below highlights only the two Checkout+-specific fields on the Create Order payload — the rest of the order shape (line items, addresses, totals, customer, etc.) is unchanged. See the [Create Order reference](/api-reference/latest/orders/create-order) for the full schema, headers, and authentication.

    ```json theme={null}
    {
      "...": "standard Create Order fields — see the Create Order reference",
      "fees": [
        {
          "name": "Order protection",
          "amount": { "amount": 498, "currency_code": "USD" },
          "accepted_offer_mode": ["returnCoverage", "shippingProtection"]
        }
      ],
      "metadata": [
        { "key": "session_id", "value": "lop_a1b2c3d4e5f6g7h8i9j0" }
      ]
    }
    ```

    The `accepted_offer_mode` array tells Loop which coverage modes the customer paid for:

    | `accepted_offer_mode`                      | Coverage activated                           |
    | ------------------------------------------ | -------------------------------------------- |
    | `["returnCoverage"]`                       | Return coverage only                         |
    | `["shippingProtection"]`                   | Shipping protection only                     |
    | `["returnCoverage", "shippingProtection"]` | Both return coverage and shipping protection |

    The `session_id` metadata entry tells Loop **which offer session** this acceptance corresponds to.
  </Step>
</Steps>

***

## Shopify Integration

This section replaces steps 3 and 4 above for **Shopify storefronts that offer Checkout+ via this API instead of Loop's Shopify checkout extension** (for example, headless / Hydrogen builds). Steps 1 and 2 — calling `/v1/analyze` and handling the response — are identical to the rest of this guide.

Shopify is different because **Loop already ingests Shopify orders directly**. You do **not** push the order to the Loop Order API. Instead, you signal the accepted offer on the Shopify **cart** itself, and Loop reads those signals from the order it ingests.

```mermaid theme={null}
sequenceDiagram
    participant Store as Your Storefront / Headless
    participant CpApi as Checkout+ API
    participant Cart as Shopify Cart
    participant Loop as Loop (direct ingestion)

    Store->>CpApi: POST /v1/analyze (platform: "shopify")
    CpApi-->>Store: sessionId, eligible, mode[], chargeInstructions, cartAttributes

    alt Customer accepts
        Store->>Cart: Add coverage product line item with lineItemProperties (_mode)
        Store->>Cart: Set cart attributes (__loop_session_id)
    else Customer declines
        Store->>Cart: Set cart attributes (__loop_session_id)
        Note over Cart: No coverage line item added
    end

    Cart->>Loop: Order ingested directly from Shopify (no Order API call)
```

### Two signals

A Checkout+ purchase is identified on a Shopify order by two signals, which the analyze response hands you pre-formatted — copy them onto the cart **verbatim** rather than constructing the keys or values yourself. Loop owns the exact keys and formatting and may change them without requiring a code change on your side.

| Signal                                                 | Where it goes                                         | When you write it      | What it carries                                                                                    |
| ------------------------------------------------------ | ----------------------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------- |
| `chargeInstructions.lineItemProperties` (e.g. `_mode`) | A **line item property** on the coverage product line | **Accept only**        | Which coverage type(s) the customer paid for.                                                      |
| `cartAttributes` (e.g. `__loop_session_id`)            | A **cart attribute**                                  | **Accept and decline** | Links the order to its offer session. Cart-scoped, so it survives even when the customer declines. |

### The response shape

When the cart is eligible on Shopify, `/v1/analyze` returns `chargeInstructions.method = "product_line_item"` along with the coverage `product` to add and the `lineItemProperties` to set on it. The session-linkage signal is returned at the top level as `cartAttributes`, so it's available whether or not the customer accepts.

```json theme={null}
{
  "sessionId": "lop_a1b2c3d4e5f6g7h8i9j0",
  "eligible": true,
  "reason": "eligible",
  "mode": ["returnCoverage", "shippingProtection"],
  "chargeInstructions": {
    "amount": 498,
    "currencyCode": "USD",
    "method": "product_line_item",
    "product": {
      "id": "8001234567890",
      "handle": "loop-order-protection"
    },
    "lineItemProperties": { "_mode": "returnCoverage,shippingProtection" }
  },
  "cartAttributes": { "__loop_session_id": "lop_a1b2c3d4e5f6g7h8i9j0" },
  "metadata": {
    "priceBreakdown": [
      { "type": "base", "amount": 298, "label": "Return coverage" },
      { "type": "base", "amount": 200, "label": "Shipping protection" }
    ],
    "pricingSubtotalUsed": 5000,
    "experimentCohort": null,
    "sessionCreated": true,
    "analysisCount": 1
  }
}
```

As everywhere else in this API, when the cart is ineligible `chargeInstructions` is `null` and you should not present the offer.

<Steps>
  <Step title="Add the coverage product on accept" noAnchor="true">
    If the customer accepts, add the product from `chargeInstructions.product` to the Shopify cart as a line item, and set the properties from `chargeInstructions.lineItemProperties` on that line.

    Add the **exact** product returned in `chargeInstructions.product` — it is the Loop-provisioned coverage product registered for your shop. A generic "protection" product you create yourself will not be recognized as Checkout+ coverage.

    ```javascript theme={null}
    // Shopify Storefront API (cartLinesAdd) — illustrative
    // Resolve the coverage product's variant from chargeInstructions.product
    // (by handle or product id), then add it as a line:
    await cartLinesAdd({
      cartId,
      lines: [
        {
          merchandiseId: coverageVariantId, // variant of chargeInstructions.product
          quantity: 1,
          attributes: Object.entries(chargeInstructions.lineItemProperties).map(
            ([key, value]) => ({ key, value })
          ), // e.g. [{ key: "_mode", value: "returnCoverage,shippingProtection" }]
        },
      ],
    });
    ```
  </Step>

  <Step title="Set the cart attributes (accept and decline)" noAnchor="true">
    Set the entries from the top-level `cartAttributes` on the cart in **both** the accept and decline cases. Because the attribute is cart-scoped, it carries the session linkage onto the order even when the customer declines and no coverage line item exists.

    ```javascript theme={null}
    // Shopify Storefront API (cartAttributesUpdate) — illustrative
    await cartAttributesUpdate({
      cartId,
      attributes: Object.entries(cartAttributes).map(([key, value]) => ({
        key,
        value,
      })), // e.g. [{ key: "__loop_session_id", value: "lop_a1b2c3d4e5f6g7h8i9j0" }]
    });
    ```
  </Step>

  <Step title="Let Loop ingest the order" noAnchor="true">
    Complete the checkout normally. Shopify carries the line item property and cart attribute onto the order, and Loop ingests that order directly — **do not** push it to the Loop Order API. Posting a Shopify order to the Order API as well would double-report it.
  </Step>
</Steps>

<Warning>
  **Copy `lineItemProperties` and `cartAttributes` verbatim.** Don't re-derive `_mode` from `mode[]`, rename the keys, or change the underscore prefixes. Loop owns the key names, value formatting, and Shopify privacy conventions; spreading the returned blocks as-is keeps your integration correct if any of those change.
</Warning>

<Tip>
  Pass `platform: "shopify"` on the `/v1/analyze` request so Loop returns the Shopify-shaped charge instructions (`product_line_item`) and the `cartAttributes` block.
</Tip>

***

## Checkout+ UI Guidelines

Your checkout UI controls how the protection offer is presented to customers. Loop does not inject any UI — your integration is responsible for rendering the offer, handling customer interaction, and reflecting the charge in the cart total.

<Warning>
  **The elements below are required, not optional.** Loop's attach-rate guarantees and the conversion commitments in your Checkout+ agreement assume the offer is presented exactly as specified — a clear coverage value proposition and a primary/secondary action pair. Integrations that omit these elements, bury the offer, or de-emphasize the accept action consistently under-perform and put contractual adoption targets at risk. Treat this section as the minimum bar for a compliant Checkout+ surface.
</Warning>

### Reference Implementation

The screenshots below show a compliant Checkout+ surface rendered in a cart drawer. **Build to match this layout and emphasis.**

<Frame caption="Required Checkout+ offer: value proposition and a primary accept / secondary decline action pair.">
  <img src="https://mintcdn.com/loopreturns/pH2lRYfTcdTHOrDf/images/checkout-plus-cart-offer.png?fit=max&auto=format&n=pH2lRYfTcdTHOrDf&q=85&s=d2831b3e819462068f24d94a901c4a1c" alt="Checkout+ offer rendered in the cart drawer with a package protection line item, a primary Checkout+ button, and a secondary continue-without-coverage button" width="473" height="1024" data-path="images/checkout-plus-cart-offer.png" />
</Frame>

The offer surface above includes every required element:

| Element                      | Requirement                                                                                                                                                                             |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Coverage line + price**    | A clearly labeled coverage line ("Checkout+ with package protection") with the price from `chargeInstructions`, shown alongside the cart total.                                         |
| **Value proposition**        | A short benefit-oriented description ("Avoid return handling fees and get coverage for items lost or damaged in transit").                                                              |
| **Primary accept action**    | The Checkout+ button that proceeds **with** coverage, rendered in the theme's **primary** color. Label it clearly (e.g. "Checkout+ \| \$754.95" with the cart total including the fee). |
| **Secondary decline action** | A "Continue without coverage" button rendered in the theme's **secondary** color (de-emphasized / outlined) that proceeds **without** the fee.                                          |

<Warning>
  Do not invert button emphasis. The accept action must use the theme's **primary** color; the decline action must use the **secondary** color. Styling both actions identically, or putting the decline action in the primary color, measurably depresses attach rate and violates the presentation expectations in your Checkout+ agreement.
</Warning>

<Tip>
  **Optional:** An info (ⓘ) control that opens a modal with fuller coverage details is a supported pattern but not required. If you include it, the modal content should accurately reflect the modes in `mode[]` — see the example below.
</Tip>

<Frame caption="Optional info modal (example): opens from an ⓘ affordance when you choose to include one.">
  <img src="https://mintcdn.com/loopreturns/pH2lRYfTcdTHOrDf/images/checkout-plus-info-modal.png?fit=max&auto=format&n=pH2lRYfTcdTHOrDf&q=85&s=1847a9ccca994ff24824f263cbe318e0" alt="Free Returns + Package Protection info modal explaining return and shipping coverage benefits" width="1024" height="744" data-path="images/checkout-plus-info-modal.png" />
</Frame>

### Placement

Display the protection offer **after the cart summary and before payment**. The offer should feel like a natural part of the checkout flow, not a popup or interstitial.

<Tip>
  Place the offer where customers are already reviewing costs. This context makes the price feel incremental rather than unexpected.
</Tip>

### Loading and Error States

Your UI should handle the time between calling the API and receiving a response, as well as failures:

| State                              | Recommendation                                                                                                              |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| **Loading**                        | Hide the offer or show a skeleton/placeholder until the API responds. Do not show stale pricing from a previous cart state. |
| **Error / timeout**                | Hide the offer entirely. Do not block checkout if the Checkout+ API is unavailable.                                         |
| **Ineligible** (`eligible: false`) | Hide the offer. Do not show disabled or grayed-out accept/decline buttons.                                                  |

<Warning>
  Checkout+ should never block the checkout flow. If the API call fails, times out, or returns an error, proceed with checkout normally — without the protection offer.
</Warning>

### Updating on Cart Changes

Re-call `POST /v1/analyze` whenever the cart contents change (items added, removed, or quantities updated). Pass the same `cartId` (or `sessionId` from the first response) so you re-use the existing offer session.

When the cart changes:

1. Call `/v1/analyze` with the updated cart data and the same `cartId` / `sessionId`.
2. Update the coverage-line price, accept-button total, and any other displayed amounts from the new `chargeInstructions`.
3. If the customer already chose accept or decline, preserve that choice across updates unless eligibility or `mode` changes make it invalid (see below).

### Eligibility Changes

If a previously eligible cart becomes ineligible after a change (e.g. an excluded product is added), the next `/analyze` response will return `eligible: false` and `chargeInstructions: null`. Hide the offer immediately and clear any stored accept selection — you must not apply a fee against a stale `chargeInstructions`.

### Mode Changes

The `mode` array can change between `/analyze` calls as the cart changes (for example, removing a non-shippable item may drop `shippingProtection` from the array, leaving only `["returnCoverage"]`). When array membership changes, re-render the value proposition, coverage-line label, and button prices so the customer sees what they're actually being offered. If the change is material, ask them to choose again rather than silently keeping a prior accept.

### Accessibility

Follow standard accessibility practices for the offer UI:

* Use native `<button>` elements (or ARIA-equivalent controls) for the accept and decline actions, with clear accessible names that include the outcome (e.g. "Checkout with protection, \$754.95" / "Continue without coverage").
* Ensure both actions are keyboard-focusable and operable without a pointer.
* Maintain sufficient color contrast for button labels, the value proposition, and price text — including when using theme primary/secondary colors.

***

## Acceptance Determination

There is no explicit acceptance or rejection API call. Loop determines acceptance from the order it receives — either the payload you send to the [Loop Order API](/api-reference/latest/orders/create-order) (Order API platforms) or the order it ingests directly from Shopify.

**Order API platforms:**

| Outcome       | How Loop determines it                                                                                                                                        |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Accepted**  | The order arrives with a `fees[]` entry whose `accepted_offer_mode` is set, and a `session_id` metadata entry. Coverage is activated and the order is billed. |
| **Declined**  | The order arrives with no protection fee (no `fees[]` entry carrying `accepted_offer_mode`). No coverage, no billing.                                         |
| **Not shown** | `/v1/analyze` returned `eligible: false`. No offer was presented.                                                                                             |
| **Abandoned** | The cart was analyzed but no order was placed. No action needed.                                                                                              |

This approach is more reliable than a label-matching heuristic because the fee classifies the acceptance (`accepted_offer_mode`) and the metadata pairs that acceptance with the exact offer session that priced it (`session_id`).

<Note>
  **Shopify:** Loop determines acceptance from the order it ingests directly from Shopify. The coverage product line item — carrying the `_mode` line item property from `chargeInstructions.lineItemProperties` — activates coverage, and the `__loop_session_id` cart attribute links the order to its offer session. There is no Order API call in this flow.
</Note>

***

## Cancelling Protected Orders

<Note>
  This applies to **Order API platforms**. On Shopify, cancellations are ingested directly from Shopify along with the rest of the order lifecycle — you don't sync them through the Order API.
</Note>

When a customer cancels an order that accepted Checkout+ protection on your platform, you must sync that cancellation back to Loop so coverage and billing can be reversed. Push an update via the [Update Order](/api-reference/latest/orders/update-order) or [Upsert Order](/api-reference/latest/orders/upsert-order) endpoint — the same order-sync pipeline you use for fulfillments and refunds.

<Warning>
  **Both fields are required.** Include `status: "cancelled"` **and** `cancelled_at` on the order payload. Sending `status` alone is not sufficient — Loop uses the timestamp to reverse self-managed package protection.
</Warning>

| Field          | Requirement                                                                                                         |
| -------------- | ------------------------------------------------------------------------------------------------------------------- |
| `status`       | Set to `"cancelled"`.                                                                                               |
| `cancelled_at` | RFC 3339 / ATOM timestamp of when the order was cancelled, without milliseconds (e.g. `2026-06-05T14:30:00+00:00`). |

```json theme={null}
{
  "status": "cancelled",
  "cancelled_at": "2026-06-05T14:30:00+00:00"
}
```

Subscribe to your platform's order-cancellation events (or include cancellation in your reconciliation job) so protected orders are updated in Loop promptly after they are cancelled on your side.

***

## Eligibility Reasons

Every `/v1/analyze` response carries a `reason` that explains the eligibility outcome. When `eligible` is `true`, `reason` is always `eligible`. When `eligible` is `false`, `reason` describes the disqualifying condition — use it to debug configuration issues, understand offer-attachment gaps, and drive any merchant-facing debug tooling you build on top of Checkout+.

| `reason`                                        | Meaning                                                                                                    | What to do                                                                                                    |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `eligible`                                      | Cart is eligible; `mode[]` and `chargeInstructions` are populated.                                         | Show the offer.                                                                                               |
| `configuration_missing`                         | No Checkout+ configuration exists for this merchant.                                                       | Reach out to [support@loopreturns.com](mailto:support@loopreturns.com) to enable Checkout+.                   |
| `configuration_invalid`                         | Checkout+ configuration exists but is malformed.                                                           | Contact Loop support — this is a server-side fix, not a merchant request issue.                               |
| `order_protection_disabled`                     | Checkout+ is disabled for this merchant.                                                                   | Re-enable from the Loop admin or contact support.                                                             |
| `empty_cart`                                    | Cart has no line items.                                                                                    | Hide the offer. Don't call `/v1/analyze` until the cart has at least one line.                                |
| `cart_only_has_coverage_products`               | Cart contains only Loop coverage products — nothing to protect.                                            | Hide the offer.                                                                                               |
| `cart_only_has_excluded_products`               | Every item in the cart matches an exclusion rule.                                                          | Hide the offer.                                                                                               |
| `cart_has_excluded_product`                     | At least one item matches an exclusion rule and the merchant's configuration treats that as disqualifying. | Hide the offer.                                                                                               |
| `b2b_customer_excluded`                         | `customer.isB2B` is `true` and the merchant excludes B2B carts.                                            | Hide the offer. Only occurs when you pass the optional `customer` block.                                      |
| `pickup_delivery_selected`                      | A `deliveryGroups[].deliveryMethod` is `PICKUP` or `LOCAL_DELIVERY`.                                       | Hide the offer. Only occurs when you pass `deliveryGroups`.                                                   |
| `cart_total_too_low`                            | Cart subtotal is below the minimum threshold configured for this merchant.                                 | Hide the offer. May become eligible after more items are added.                                               |
| `cart_too_valuable`                             | Cart subtotal exceeds the maximum coverage amount.                                                         | Hide the offer.                                                                                               |
| `locale_not_supported`                          | The customer's locale is not in the merchant's supported locales.                                          | Hide the offer.                                                                                               |
| `currency_not_supported`                        | `cart.currencyCode` is not in the merchant's supported currencies.                                         | Hide the offer.                                                                                               |
| `ab_test_affects_attachment_rate_control_group` | Session was assigned to an experiment control group that suppresses the offer.                             | Hide the offer. Expected when you opt in to experimentation.                                                  |
| `rule_evaluation_error`                         | A merchant-configured eligibility rule threw during evaluation.                                            | Hide the offer — do not block checkout. Report the `X-Request-Id` to Loop support if the rate is non-trivial. |
| `system_error`                                  | Loop encountered an unexpected error while evaluating eligibility.                                         | Hide the offer — do not block checkout. Safe to retry the next time the cart changes.                         |

<Tip>
  For every ineligible reason, `chargeInstructions` is `null` and `mode` is omitted from the response. Treat any `reason` other than `eligible` as "hide the offer" — never present a disabled / grayed-out toggle.
</Tip>

***

## Charge Instructions

When the cart is eligible, the response includes a `chargeInstructions` object that tells your integration how to apply the protection charge.

| Field                | Description                                                                                                                                                                                                                                                          |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `amount`             | The fee amount in minor currency units (e.g., `498` = \$4.98 USD).                                                                                                                                                                                                   |
| `currencyCode`       | ISO 4217 currency code for the charge.                                                                                                                                                                                                                               |
| `method`             | The charge mechanism. `fee` for Order API platforms (WooCommerce, BigCommerce, non-Shopify headless), where you apply the charge as a native order-level fee. `product_line_item` for Shopify, where the charge is applied by adding a coverage product to the cart. |
| `product`            | Only present when `method` is `product_line_item`. Contains `id` and `handle` for the Shopify coverage product to add to the cart.                                                                                                                                   |
| `lineItemProperties` | Only present when `method` is `product_line_item`. A map of line item properties (e.g. `_mode`) to set on the coverage product line. Copy verbatim — see [Shopify Integration](#shopify-integration).                                                                |

When the cart is ineligible, `chargeInstructions` is `null`.

On Shopify, the session-linkage signal is returned separately, at the top level of the response as [`cartAttributes`](#shopify-integration) — not inside `chargeInstructions` — so it is available whether the customer accepts or declines.

***

## Shipping Protection and Delivery Methods

When your merchant configuration includes `shippingProtection`, pass `deliveryGroups` on every `/v1/analyze` call so Loop can detect pickup or local-delivery selections that disqualify the cart from shipping coverage. If you omit `deliveryGroups`, Loop assumes the order is being shipped and will offer shipping protection on in-store pickup orders.

Each entry in `deliveryGroups` carries a `deliveryMethod`:

| `deliveryMethod` | Meaning                           | Effect on shipping coverage                       |
| ---------------- | --------------------------------- | ------------------------------------------------- |
| `SHIPPING`       | Standard shipped delivery         | Eligible                                          |
| `PICKUP`         | In-store or curbside pickup       | Disqualifies (`reason: pickup_delivery_selected`) |
| `LOCAL_DELIVERY` | Merchant-operated local delivery  | Disqualifies (`reason: pickup_delivery_selected`) |
| `DIGITAL`        | Digital / no physical fulfillment | Eligible, but typically no shipping exposure      |

Map your platform's native delivery types to these generic values before sending — for example WooCommerce's `local_pickup` → `PICKUP`, BigCommerce store pickup → `PICKUP`.

<Tip>
  Merchants whose protection mode is `returnCoverage` only do not need to send `deliveryGroups` — return coverage doesn't depend on how the order is fulfilled.
</Tip>

***

## Inspecting an Existing Session

`GET /v1/session/{sessionId}` returns the current state of an offer session without re-analyzing the cart. Use it from any context where you have the `sessionId` but don't have (or don't want to re-send) the cart payload — for example a debug surface, an admin tool, or a downstream worker auditing offer outcomes.

```bash cURL theme={null}
curl https://api.loopreturns.com/storefront/v1/session/lop_a1b2c3d4e5f6g7h8i9j0 \
  -H "X-Authorization: YOUR_MERCHANT_API_KEY"
```

The response is the same shape as `POST /v1/analyze` — `sessionId`, `eligible`, `reason`, `mode`, `chargeInstructions`, `metadata` — with two differences:

| Field                     | Behavior                                                                                                                                                           |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `createdAt` / `updatedAt` | Present on `GET`, not on `POST /v1/analyze`. RFC 3339 / ATOM timestamps without milliseconds (e.g. `2026-04-23T13:25:00+00:00`) that bound the session's lifetime. |
| `metadata.sessionCreated` | Omitted on `GET` (it only describes the analyze call that created or re-used the session, not the session itself).                                                 |

### Caveats

* Sessions are merchant-scoped. A session created under one merchant API key is invisible to others — you'll get `404 session_not_found` from the wrong key.
* Responses are sent with `Cache-Control: private, max-age=45`. A short shared cache is fine for poll loops; do not cache across merchants or longer than 45 seconds.
* This endpoint is **not** the right way to drive checkout UI updates — always re-call `POST /v1/analyze` when the cart contents change so pricing reflects the latest state.

***

## Error Handling

All error responses use a consistent envelope:

```json theme={null}
{
  "error": {
    "code": "validation_error",
    "message": "Request validation failed",
    "details": {
      "cart.currencyCode": "Required",
      "cart.totalAmount": "Expected number, received string"
    }
  }
}
```

| HTTP Status | Error Code            | Condition                                                                                             |
| ----------- | --------------------- | ----------------------------------------------------------------------------------------------------- |
| 400         | `invalid_request`     | Malformed JSON, wrong `Content-Type`, body over 1 MB, or invalid `sessionId` format                   |
| 400         | `validation_error`    | Schema validation failure — check `error.details` for per-field errors                                |
| 401         | `unauthorized`        | Missing or invalid API key — see [Authentication](/api-reference/authentication)                      |
| 404         | `not_found`           | Unknown route — check the URL and HTTP method                                                         |
| 404         | `session_not_found`   | Well-formed `sessionId` (or path parameter) does not exist for this merchant                          |
| 405         | `method_not_allowed`  | Wrong HTTP method for the endpoint (e.g. `GET` on `/v1/analyze`)                                      |
| 429         | `rate_limited`        | Per-merchant rate limit exceeded — retry after the indicated period                                   |
| 500         | `internal_error`      | Unexpected server error — safe to retry once                                                          |
| 503         | `service_unavailable` | Upstream dependency (typically merchant configuration) is temporarily unavailable. Retry with backoff |

Every response includes an `X-Request-Id` header for log correlation. Include this value when contacting support about a specific request.

***

## Idempotency

You can re-use an existing offer session in two ways:

| Method          | How                                                                              | When to use                                                                                                                                    |
| --------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| **`sessionId`** | Pass the `sessionId` from a previous response at the top level of the request    | You already have a session and want to re-analyze with updated cart data                                                                       |
| **`cartId`**    | Pass a stable cart identifier from your platform at the top level of the request | Your platform provides a consistent cart ID across checkout (e.g. BigCommerce's `cart_id`) and you'd rather not persist `sessionId` separately |

Either approach updates the existing analysis rather than creating a new one. The response will reflect the new pricing for the updated cart, and `metadata.sessionCreated` will be `false` with `metadata.analysisCount` incremented.

### `sessionId` semantics

* The format is fixed: `^lop_[A-Za-z0-9]{20}$` (e.g. `lop_a1b2c3d4e5f6g7h8i9j0`).
* A request with a malformed `sessionId` fails with `400 invalid_request`. Omit the field to let Loop generate a new session.
* A well-formed `sessionId` that does not exist for your merchant fails with `404 session_not_found`. This usually means the session was never created under this API key, was created against a different environment, or has been evicted.
* When both `sessionId` and `cartId` are sent, `sessionId` takes precedence — Loop looks up the session directly and ignores `cartId` for session selection.

### `cartId` vs `cart.id`

These are two different fields with different roles — easy to confuse:

| Field     | Where                            | Role                                                                                                                                                                       |
| --------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cartId`  | Top-level on the analyze request | **Merchant-scoped session key.** Loop indexes the cart against the offer session; subsequent `/analyze` calls with the same `cartId` re-use the same session.              |
| `cart.id` | Inside the `cart` object         | **On-platform identifier**, echoed verbatim and surfaced in analytics / debug output. Has no idempotency role — sending only `cart.id` will not re-use a previous session. |

If you want idempotent re-analysis, you must send the value at the top level as `cartId` (or pass `sessionId`).

<Tip>
  BigCommerce provides a stable `cart_id` automatically. For WooCommerce and headless platforms, use whatever stable cart identifier your platform provides, or pass back the `sessionId` from the initial analysis.
</Tip>

***

## Debugging

Set `debug: true` on the request to receive a `debug` block on the response with eligibility, pricing, and configuration diagnostics. Use this only during integration testing — the field is gated behind the request flag and is not intended for production use.

Always include the `X-Request-Id` from the response header when reporting issues to Loop support. It is the fastest way for us to pull the full request/response trace from our logs.

***

## Analytics & Experimentation

`/v1/analyze` accepts a handful of optional fields that do not influence pricing or eligibility but help Loop segment attach-rate reporting and run A/B tests on your behalf. They're safe to omit — pass them when you want the analytics value.

| Field                    | Type      | Purpose                                                                                                                                                                                                                                                                                |
| ------------------------ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `experimentationConsent` | `boolean` | Opt the session in to A/B test cohort assignment. When `true`, Loop may assign a cohort and return it in `metadata.experimentCohort`; when `false` or omitted, `metadata.experimentCohort` is always `null`. Required if you want this session to participate in Loop-run experiments. |
| `offerOrigin`            | `string`  | Where the offer is being presented — e.g. `"checkout"`, `"cart"`. Defaults to `"custom"` when omitted. Analytics only.                                                                                                                                                                 |
| `mountTarget`            | `string`  | UI mount location — e.g. `"below_shipping"`, `"order_summary"`. Defaults to `"custom"` when omitted. Analytics only.                                                                                                                                                                   |
| `cartExperience`         | `string`  | UI experience variant identifier — e.g. `"widget"`, `"modal"`. Defaults to `"api"` when omitted. Analytics only.                                                                                                                                                                       |

<Tip>
  If a session is assigned to an experiment control group that suppresses the offer, `/v1/analyze` will return `eligible: false` with `reason: ab_test_affects_attachment_rate_control_group`. Treat this the same as any other ineligible outcome — hide the offer.
</Tip>

***

## Technical Considerations

| Consideration                      | Requirement                                                                                                                                                                                                                                                                                        |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Monetary values**                | All amounts on `/v1/analyze` are in **minor currency units** (cents). `$10.00` = `1000`. The Order API uses major-unit decimal strings — convert when you push the fee.                                                                                                                            |
| **Currency codes**                 | ISO 4217 format (e.g., `USD`, `EUR`, `GBP`).                                                                                                                                                                                                                                                       |
| **Cart updates**                   | Re-call `/v1/analyze` whenever the cart changes to get updated pricing, passing the same `cartId` or `sessionId`.                                                                                                                                                                                  |
| **Mode mapping (Order API)**       | Pass `mode[]` through to `fees[].accepted_offer_mode` verbatim — do not collapse, sort, or transform the array.                                                                                                                                                                                    |
| **Session linkage (Order API)**    | Pass the `sessionId` from `/v1/analyze` through to the order as a `metadata` entry with `key: "session_id"`. Use the key name exactly.                                                                                                                                                             |
| **Shopify integrations**           | Do not POST Shopify orders to the Order API. Add the coverage product from `chargeInstructions` with its `lineItemProperties`, set the top-level `cartAttributes` on the cart, and let Loop ingest the order directly. Copy both blocks verbatim. See [Shopify Integration](#shopify-integration). |
| **Order cancellation (Order API)** | When a protected order is cancelled on your platform, update it in Loop with `status: "cancelled"` and `cancelled_at` so protection coverage is reversed. See [Cancelling Protected Orders](#cancelling-protected-orders).                                                                         |
| **Request body size**              | The `/v1/analyze` endpoint enforces a **1 MB** request body limit. Larger payloads are rejected with `400 invalid_request` ("Request body too large"). Trim oversized cart attributes (e.g. raw HTML or base64-encoded blobs) before sending.                                                      |
| **Additive changes**               | Loop may add new fields to API responses at any time. Build your integration to tolerate unknown fields.                                                                                                                                                                                           |

***

## Related Resources

<CardGroup cols={3}>
  <Card title="Analyze Cart — API Reference" icon="code" href="/api-reference/latest/checkout-plus/analyze-cart">
    Full request and response schema for the `POST /v1/analyze` endpoint.
  </Card>

  <Card title="Create Order — API Reference" icon="receipt" href="/api-reference/latest/orders/create-order">
    For Order API platforms (non-Shopify): where to push the order with `fees[].accepted_offer_mode` and the `session_id` metadata entry so Loop can activate Checkout+ coverage and link the order to its offer session.
  </Card>

  <Card title="Loop Anywhere" icon="plug" href="/integration-guides/loop-anywhere">
    Push commerce data into Loop from any source — orders, products, customers, and more.
  </Card>
</CardGroup>
