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 toPOST /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 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. |
/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 (Order API) and Shopify Integration.
How It Works
Shopify integrators: follow steps 1–2 below, then skip to Shopify Integration.
- 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 asessionId, the resolved coveragemode[], andchargeInstructions. - Present the offer — If
eligibleistrue, display the protection offer to the customer using the price inchargeInstructions.amountand copy that matches the modes inmode[]. - 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.
- Report the fee and session to Loop (non-Shopify platforms) — Once the order is created, push it to the Loop Order API with both:
- A
fees[]entry for the protection charge, withfees[].accepted_offer_modeset to themodearray returned by/v1/analyze, verbatim. Loop uses this to recognize the charge as a Checkout+ acceptance and activate coverage. - A
metadataentry withkey: "session_id"andvalueset to thesessionIdreturned by/v1/analyze.
- A
Base URL
All Checkout+ endpoints are served from: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 theX-Authorization header — see 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.
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) |
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.
Integration Steps
All integrations follow steps 1 and 2. Shopify integrators: skip steps 3 and 4 — see Shopify Integration.
1
Call the Analyze endpoint
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— withlines,currencyCode,subtotalAmount,totalDiscountAmount,discountedSubtotalAmount,totalTaxAmount,totalAmount, anditemCount.deliveryGroups— required only when your merchant configuration includesshippingProtection. See Shipping Protection and Delivery Methods.
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.2
Handle the response
The response tells you whether the cart is eligible and, if so, what coverage was offered and how to apply the charge.
If
| 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. |
eligible is false, chargeInstructions is null — do not show the offer.3
Apply the charge
Shopify integrators: skip to Shopify Integration.
chargeInstructions.Convert chargeInstructions.amount from minor currency units (e.g. 498 → 4.98) when your platform expects major units.4
Report the fee and session to Loop
Once the order is created on your platform, push it to the Loop Order API (or upsert it via your existing order-sync pipeline). The order payload must include both:The
The
- A
fees[]entry for the protection charge, withaccepted_offer_modeset to themodearray returned by/v1/analyze, verbatim — do not transform, sort, or stringify the array. - A
metadataentry withkey: "session_id"andvalueset to thesessionIdreturned by/v1/analyze(e.g.lop_a1b2c3d4e5f6g7h8i9j0).
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 |
session_id metadata entry tells Loop which offer session this acceptance corresponds to.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.
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.
chargeInstructions is null and you should not present the offer.
1
Add the coverage product on accept
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.2
Set the cart attributes (accept and decline)
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.3
Let Loop ingest the order
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.
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.Reference Implementation
The screenshots below show a compliant Checkout+ surface rendered in a cart drawer. Build to match this layout and emphasis.
| 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. |

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.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. |
Updating on Cart Changes
Re-callPOST /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:
- Call
/v1/analyzewith the updated cart data and the samecartId/sessionId. - Update the coverage-line price, accept-button total, and any other displayed amounts from the new
chargeInstructions. - If the customer already chose accept or decline, preserve that choice across updates unless eligibility or
modechanges 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
Themode 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 (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. |
accepted_offer_mode) and the metadata pairs that acceptance with the exact offer session that priced it (session_id).
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.Cancelling Protected Orders
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.
| 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). |
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 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. |
Charge Instructions
When the cart is eligible, the response includes achargeInstructions 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. |
chargeInstructions is null.
On Shopify, the session-linkage signal is returned separately, at the top level of the response as cartAttributes — not inside chargeInstructions — so it is available whether the customer accepts or declines.
Shipping Protection and Delivery Methods
When your merchant configuration includesshippingProtection, 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 |
local_pickup → PICKUP, BigCommerce store pickup → PICKUP.
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.
cURL
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_foundfrom 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/analyzewhen the cart contents change so pricing reflects the latest state.
Error Handling
All error responses use a consistent envelope:| 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 |
| 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 |
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 |
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
sessionIdfails with400 invalid_request. Omit the field to let Loop generate a new session. - A well-formed
sessionIdthat does not exist for your merchant fails with404 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
sessionIdandcartIdare sent,sessionIdtakes precedence — Loop looks up the session directly and ignorescartIdfor 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. |
cartId (or pass sessionId).
Debugging
Setdebug: 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. |
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. |
| 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. |
| 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
Analyze Cart — API Reference
Full request and response schema for the
POST /v1/analyze endpoint.Create Order — API Reference
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.Loop Anywhere
Push commerce data into Loop from any source — orders, products, customers, and more.