Add to Cart
Let shoppers add, remove, and change products in your store's own cart from inside the Loops AI widget. Your storefront listens for four browser events and replies with LoopsAI.cartResult.
With Add to Cart turned on, shoppers can put a recommended product into your store's cart without leaving the chat. They can also open a cart view inside the widget, change quantities, remove lines, and go to checkout.
Loops AI keeps no cart of its own. Every cart change runs through the cart code your storefront already uses, so stock checks, sessions, cart badges, mini-cart drawers, and your own analytics keep working as they do today. The widget asks, your storefront does the work and answers, and the widget shows the answer.
What you build
One listener for each of four window events (loopsai:addToCart, loopsai:removeFromCart, loopsai:updateQuantity, loopsai:getCart), and one reply for every event through window.LoopsAI.cartResult(...). Nothing else is required on your side.
How it works
- The shopper taps Add to Cart on a product card, in the product details sheet, or on an outfit or look. If the product has several sizes or colors, the widget first shows a picker and resolves the exact sellable variant.
embed.js, which runs on your page, dispatches aCustomEventonwindowwith a uniquerequestId.- Your listener runs your normal cart code and calls
LoopsAI.cartResultwith the samerequestId. - The widget shows the outcome. It then re-reads your cart with
loopsai:getCartunless your reply already included the cart.
Before you start
| Requirement | Why it matters |
|---|---|
The widget is installed with embed.js on your site | The events are dispatched by embed.js on your page. Add to Cart is not available in the mobile SDKs or when the chat URL is opened directly without embed.js. |
Your product feed carries variants, each with a barcode (or a variant id), and size / color where they apply | The variant code is what the widget sends as productCode. See Product codes. |
| Your page JavaScript can add, remove, and change cart lines by that code | This is the code your listener calls. Most storefronts already have it for their own product page and mini-cart. |
| You can read the current cart in the browser | loopsai:getCart must return the live cart. |
Integration steps
Agree on the product code
Confirm which identifier your cart code accepts (SKU, EAN/barcode, or a platform variant ID) and make sure that value is in the barcode field of each variant in your Loops AI product feed. Do this first: everything else depends on it. See Product codes.
Add the listeners to every page that loads the widget
Copy the reference listener into a script that runs on every page where the widget loads, before or right after the embed.js snippet. Replace the four adapter functions with calls into your store.
Test with the feature off for shoppers
Run through the test checklist on a staging site, or on production with the browser console mock first.
Turn it on in the dashboard
In the Loops AI dashboard, open the web channel configurator for your agent:
- Advanced Features → Add to Cart Functionality: turn it on.
- Checkout page URL (shown under the switch): optional. See Checkout button.
- Content → Product card buttons: set a button's action to Add to Cart if you want the button on product cards (in chat results and in favorites). The product details sheet, outfits, and looks show their Add to Cart button as soon as the feature is on.
Publish the configuration.
Ship the listener before you flip the switch
Once Add to Cart Functionality is on, the widget shows cart buttons and the cart icon on every page where it loads. If a page has no listener, a tap waits 10 seconds and then shows "Checking your cart…" without ever adding the product. Deploy the listener everywhere the widget runs first, then turn the switch on.
Reference listener
This is a complete, production-shaped listener. It covers all four events, always replies, maps your errors to Loops AI reasons, includes the current cart in every reply, and is safe to load more than once.
Only the four functions in storeCart are specific to your store.
(function installLoopsAICartBridge() {
if (window.__loopsAICartBridgeInstalled) return;
window.__loopsAICartBridgeInstalled = true;
const storeCart = {
async add(productCode, quantity) {
await window.MyStore.cart.add({ sku: productCode, quantity });
},
async remove(productCode) {
await window.MyStore.cart.remove({ sku: productCode });
},
async setQuantity(productCode, quantity) {
await window.MyStore.cart.update({ sku: productCode, quantity });
},
async read() {
const cart = await window.MyStore.cart.get();
return {
items: cart.lines.map((line) => ({
productCode: line.sku,
quantity: line.quantity,
name: line.title,
imageUrl: line.imageUrl,
size: line.size,
color: line.color,
unitPrice: line.unitPrice,
lineTotal: line.lineTotal,
productUrl: line.url,
})),
itemCount: cart.totalQuantity,
subtotal: cart.subtotal,
total: cart.total,
currency: cart.currencyCode,
checkoutUrl: "/checkout",
};
},
};
const KNOWN_REASONS = [
"OUT_OF_STOCK",
"INVALID_VARIANT",
"INVALID_QUANTITY",
"MAX_QUANTITY",
"PRODUCT_NOT_AVAILABLE",
"UNKNOWN_ERROR",
];
function toReason(error) {
const code = error && (error.code || error.reason);
return KNOWN_REASONS.includes(code) ? code : "UNKNOWN_ERROR";
}
function reply(result) {
const api = window.LoopsAI || window.LoopSmartAI;
if (api && typeof api.cartResult === "function") api.cartResult(result);
}
async function readCartSafely() {
try {
return await storeCart.read();
} catch {
return undefined;
}
}
function handle(eventName, run) {
window.addEventListener(eventName, async (event) => {
const detail = event.detail || {};
const requestId = detail.requestId;
if (typeof requestId !== "string") return;
try {
await run(detail);
const cart = await readCartSafely();
if (!cart && eventName === "loopsai:getCart") throw new Error("Cart unavailable");
reply({ requestId, success: true, cart });
} catch (error) {
reply({
requestId,
success: false,
reason: toReason(error),
cart: eventName === "loopsai:getCart" ? undefined : await readCartSafely(),
});
}
});
}
handle("loopsai:addToCart", ({ productCode, quantity }) =>
storeCart.add(productCode, quantity),
);
handle("loopsai:removeFromCart", ({ productCode }) =>
storeCart.remove(productCode),
);
handle("loopsai:updateQuantity", ({ productCode, quantity }) =>
storeCart.setQuantity(productCode, quantity),
);
handle("loopsai:getCart", () => undefined);
})();What this does for you:
- Always replies. Every path ends in
cartResult, including errors. A missing reply turns into a 10-second wait and an unknown outcome for the shopper. - Returns the cart with every reply. The widget updates its cart view and badge from your reply directly and skips an extra
loopsai:getCartround trip. Forloopsai:getCartthe reply must includecart, so a failed cart read is reported as a failure there. If reading your cart is slow, reply to add, remove, and update withoutcart; the widget then asks withloopsai:getCart. - Installs once. In single-page apps, scripts can run again on route changes. Two listeners for
loopsai:addToCartwould add the product twice. - Maps errors. Throw (or reject) with
error.codeset to one of the reason codes and the shopper sees a matching message.
Your storefront's UI updates as usual
Because the listener calls your normal cart code, your header cart count, mini-cart, toasts, and analytics react exactly as they do when the shopper adds from your own product page. Decide whether you want your mini-cart to pop open for adds that came from the widget; the widget already shows its own confirmation.
Event reference
All four events are CustomEvents dispatched on window. Read the payload from event.detail. Each event carries a fresh requestId. Reply to it exactly once.
| Event | event.detail | What your storefront should do |
|---|---|---|
loopsai:addToCart | { requestId, productCode, quantity } | Add quantity units of productCode. If the line already exists, increase it by quantity. |
loopsai:updateQuantity | { requestId, productCode, quantity } | Set the line to exactly quantity units. This is the new total, not a delta. |
loopsai:removeFromCart | { requestId, productCode } | Remove the whole line. |
loopsai:getCart | { requestId } | Change nothing. Reply with the current cart. |
Field rules:
| Field | Type | Guarantee |
|---|---|---|
requestId | string | Unique per request, for example loopsai_atc_4f1c…. Treat it as opaque and send it back unchanged. |
productCode | string | Never empty, already trimmed. The sellable variant code. See Product codes. |
quantity | number | Whole number, 1 or more. The widget currently sends 1 for every add; the cart view sends the new total for quantity changes. |
The widget never sends updateQuantity with 0. To take a line to zero it sends removeFromCart.
LoopsAI.cartResult(result)
The only method your storefront calls. It is available on window.LoopsAI (and the legacy alias window.LoopSmartAI) as soon as embed.js has booted, which is always true by the time an event reaches you.
interface CartResult {
requestId: string;
success: boolean;
reason?:
| "OUT_OF_STOCK"
| "INVALID_VARIANT"
| "INVALID_QUANTITY"
| "MAX_QUANTITY"
| "PRODUCT_NOT_AVAILABLE"
| "UNKNOWN_ERROR";
cart?: CartSnapshot;
}| Field | Required | Notes |
|---|---|---|
requestId | Yes | The requestId from the event you are answering. Replies with an unknown requestId are ignored. |
success | Yes | Must be the boolean true for success. Any other value is treated as a failure. |
reason | When success is false | One of the reason codes. Anything else, or no reason, is treated as UNKNOWN_ERROR. |
cart | Required for getCart, recommended for the others | The cart after the operation. See Cart snapshot. |
You may call cartResult synchronously inside the listener or later after an await. Only the first reply for a requestId counts.
Cart snapshot
interface CartSnapshot {
items: CartItem[];
itemCount?: number;
subtotal?: number;
total?: number;
currency?: string;
checkoutUrl?: string;
}
interface CartItem {
productCode: string;
quantity: number;
name?: string;
imageUrl?: string;
size?: string;
color?: string;
unitPrice?: number;
lineTotal?: number;
productUrl?: string;
}Only items[].productCode and items[].quantity are required. Send an empty cart as { items: [] }.
| Field | What the widget does with it |
|---|---|
items[].productCode | Matches the line to the product the shopper added. Use the same code the widget sent. Matching is exact and case-sensitive. |
items[].quantity | Line quantity. Whole number, 0 or more. |
items[].name | Line title. If missing, the widget uses the product it just added or looks the code up in your Loops AI catalog. |
items[].imageUrl | Line thumbnail. Same fallback as name. |
items[].size, items[].color | Shown under the title. Same fallback as name. |
items[].unitPrice | Shown next to the color, and in the "Added to Cart" confirmation. |
items[].lineTotal | Shown on the right of the line. |
items[].productUrl | Accepted; not displayed yet. |
itemCount | Number on the cart badge. If missing, the widget sums items[].quantity. |
subtotal, total | Shown at the bottom of the cart view when present. |
currency | ISO 4217 code such as "EUR" or "TRY". Used to format every amount. Without a recognised code, amounts are shown as plain numbers with two decimals. |
checkoutUrl | Target of the Go to Checkout button. See Checkout button. |
Value rules:
- Amounts are plain numbers in the currency's major unit:
49.9, not4990and not"€49,90". imageUrl,productUrl, andcheckoutUrlmay be absolute or relative to the current page. OnlyhttpandhttpsURLs are kept, up to 2,048 characters.- Text values are trimmed and cut at 512 characters. Empty strings count as missing.
- Up to 200 lines are read. Lines without a valid
productCodeorquantityare skipped. - Fields not listed above are ignored. The snapshot must be plain JSON data; functions and class instances are dropped.
Send name and image
The catalog fallback finds a line only when its code matches a product code or a variant barcode in your Loops AI catalog. Lines your shopper added outside the widget, or codes that are not in the feed, show a generic "Product" title and a placeholder image. Sending name and imageUrl avoids that.
Reason codes
Reply with these when an operation fails. Choose the closest one; the shopper sees the message in the right-hand column (shown in the widget's language).
| Reason | When to use it | What the shopper sees |
|---|---|---|
OUT_OF_STOCK | The variant has no stock. | "This size is sold out" ("This color is sold out" for products that only vary by color). The size picker stays open and marks that size as sold out. |
MAX_QUANTITY | The line is at the maximum your store allows. | "You've reached the maximum quantity". The widget disables the + button for that line at the quantity your cart reports. |
INVALID_QUANTITY | The quantity is not allowed (for example, sold in packs). | "That quantity isn't available" |
INVALID_VARIANT | Your store does not recognise productCode. | "This product can't be added right now" |
PRODUCT_NOT_AVAILABLE | The product exists but cannot be bought (unpublished, region-locked, not sold online). | "This product can't be added right now" |
UNKNOWN_ERROR | Anything else. | "Something went wrong. Please try again." |
Two more reasons come from Loops AI, never from your storefront:
| Reason | Meaning |
|---|---|
TIMEOUT | No reply within 10 seconds. The outcome is unknown. See Slow or missing replies. |
NOT_SUPPORTED | Add to Cart is off for this agent, or the embed.js on the page did not report cart support (for example, an outdated cached copy). The shopper sees "The store's cart isn't responding right now". |
Loops AI sets no quantity limit of its own. Your MAX_QUANTITY reply is the only limit.
Slow or missing replies
Your listener has 10 seconds to call cartResult.
If it does not, the widget does not assume the change failed, because your store may still have added the product. Instead it:
- Marks the line as "Checking your cart…" and blocks further taps on that line.
- Re-reads your cart with
loopsai:getCartright away and again 10 seconds later, and compares quantities. - Still accepts your original reply for up to 60 seconds. A late reply settles the line with the real outcome.
- Clears the "checking" state after 60 seconds at most.
The widget never retries a change on its own, so a slow store cannot cause a double add. Each tap is one request with one requestId. If your cart API supports idempotency keys, you can pass requestId as one.
If your add-to-cart path can take longer than a few seconds (slow network, a third-party cart API), reply as soon as your store has accepted the change rather than after every follow-up task has finished.
Keeping the widget in sync
The widget never stores the cart. It reads it from you with loopsai:getCart:
- when the widget loads on a page and whenever it is opened,
- when the shopper opens the cart view,
- after every change whose reply did not include
cart, - when the browser tab becomes visible again,
- when the page is restored from the browser's back/forward cache,
- while it checks a change that timed out.
So a product the shopper added from your own product page appears in the widget's cart the next time it opens. You do not need to notify the widget about changes made outside it.
Requests for different products can arrive at the same time. For the same product there is never more than one request in flight: a second tap while the first is pending is ignored, not queued. If a getCart reply arrives after a newer one, the widget ignores the older snapshot.
Product codes
productCode is the code of the sellable variant: the exact size and color the shopper chose, not the parent product.
The widget takes it from your Loops AI product feed:
- the variant's
barcode, if it has one, - otherwise the variant's
id.
If a product has a single variant, that variant is used directly. If it has several, the shopper picks a size (or a color, for color-only products) and the widget sends that variant's code. Variants with stock 0 in your feed are shown as sold out in the picker.
Your storefront must be able to find the line with that value. Two common setups:
| Your cart is keyed by | Put this value in the feed's variant barcode |
|---|---|
| SKU | the variant SKU |
| EAN / GTIN | the barcode |
If your cart code needs a different identifier (for example a platform variant ID), translate inside your listener: look the code up in a map on the page and pass the result to your cart code. Return the same productCode the widget sent in items[].productCode, so the widget can match the line.
Codes are treated as text
Codes are sent and compared as strings, exactly as they are in your feed. Leading zeros and letter case matter. Do not reformat them.
Checkout button
The cart view shows a Go to Checkout button when a checkout page is known. The button opens that page in the main browser window (not inside the widget).
The page comes from:
- the Checkout page URL set under Add to Cart Functionality in the dashboard, if one is set,
- otherwise
checkoutUrlfrom your latest cart snapshot.
With neither, the button is hidden. The dashboard field accepts full https:// addresses only. A checkoutUrl in the snapshot may be relative, for example "/checkout".
What the shopper sees
| Place | Behavior |
|---|---|
| Product cards (chat results and favorites) | An Add to Cart button, if a product card button's action is set to Add to Cart. |
| Product details sheet, outfits, looks | An Add to Cart button next to the existing actions. |
| Size or color picker | Opens when a product has more than one variant. Sold-out variants are marked. |
| Confirmation | A toast above the message box with the product image, size, and price, and a View cart link. |
| Chat header | A cart icon with the item count from your cart. |
| Cart view | Your cart lines with quantity steppers, remove buttons, subtotal and total, and Go to Checkout. |
The widget does not change a line's variant, does not show shipping, and does not show compare-at prices for cart lines. Cart changes from chat messages ("add the black one in M") are not supported yet; shoppers use the buttons.
Analytics
When analytics delivery is set up for your agent (see Analytics), the widget pushes two extra ecommerce events to window.dataLayer:
| Event (default name) | Fires when |
|---|---|
loops_ai_add_to_cart | Your storefront confirmed an add, or a quantity increase, made in the widget. quantity is the number of units added. |
loops_ai_remove_from_cart | Your storefront confirmed a removal, or a quantity decrease, made in the widget. quantity is the number of units removed. |
Both fire only after your storefront confirmed success (directly or through a late reply), never for failures or unknown outcomes. Both follow the GA4 ecommerce shape (ecommerce.items[] with item_id, item_name, price, quantity). You can rename or turn off each event in the dashboard's analytics settings.
Avoid double counting
Your own cart code will usually push its standard add_to_cart event too, because the listener calls the same code your product page does. That is expected: your add_to_cart counts every add, and loops_ai_add_to_cart tells you which of those came from Loops AI. Do not rename the Loops AI event to add_to_cart, or each widget add is counted twice.
Test checklist
You can test your listener before turning the feature on by calling it directly from the browser console:
const original = window.LoopsAI.cartResult;
window.LoopsAI.cartResult = (result) => {
console.log("cartResult", result);
return original(result);
};
window.dispatchEvent(
new CustomEvent("loopsai:addToCart", {
detail: { requestId: "test-1", productCode: "YOUR_VARIANT_CODE", quantity: 1 },
}),
);
window.dispatchEvent(
new CustomEvent("loopsai:getCart", { detail: { requestId: "test-2" } }),
);The product should appear in your own mini-cart, and the console should log a cartResult for test-1 and test-2 with success: true and your cart. The widget ignores these replies because it did not send those request IDs.
Then, with the feature on in a staging agent, check each row:
| Scenario | Expected |
|---|---|
| Add a single-variant product from a product card | "Added to Cart" toast, cart badge goes up, your mini-cart shows the product. |
| Add a product with sizes | Size picker opens; after choosing, the right variant is in your cart. |
| Add the same product again | Your line goes to quantity 2 (not a second line). |
| Open the cart view | Lines, names, images, prices, and totals match your own cart page. |
| + and − in the cart view | Your cart quantity changes; loopsai:updateQuantity carries the new total. |
| Remove a line | Line disappears in the widget and in your cart. |
| Out-of-stock variant | "This size is sold out", and the size is marked sold out. |
| Quantity limit | "You've reached the maximum quantity", and + is disabled. |
| Add from your own product page, then open the widget | The new line is in the widget's cart. |
| Go to Checkout | Your checkout page opens in the main window. |
| Every page type that loads the widget (home, listing, search, product, content) | Listener answers loopsai:getCart on each of them. |
| Single-page navigation, then add once | Exactly one unit is added (no duplicate listeners). |
Troubleshooting
Migrating from the old postMessage example
Earlier versions of this page described a window.addEventListener("message", …) listener for { action: "addToCart" } messages. The widget never sent those messages. If you built against that example, replace it with the reference listener above; the old listener can be removed.