Shopping Cart Sync
Synchronize the Xinfer AI shopping cart with your store's native cart using DOM events. The widget runs on your page (not an iframe), so both sides share window.
Shopping Cart Demo — Interactive cart sync demo — see events firing in real-time between the widget and this simulated host cart.
Quick Start
1. Enable Cart Sync
Set cartSync before the widget script loads:
<script>
window.XinferChat = { cartSync: true, cartInitMerge: true };
</script>
<script src="https://[subdomain].xinfer.ai/widget.js" async></script>
| Option | Default | Description |
|---|---|---|
cartSync | false | Enable cart sync events between widget and host |
cartInitMerge | true | Bi-directional merge of host and widget carts on first sync |
cartInitMerge
When the widget first connects to the host adapter, it performs a bi-directional merge between the host cart and the widget's server-side cart. This ensures both sides start in sync — items that were added via full chat, voice chat, or a previous page visit are merged into the host cart, and host-only items are saved to the widget.
The merge runs once per widget lifecycle when the host's first xinfer:cart:response arrives (triggered by the ready → request → response handshake). For each item, the higher quantity wins:
- Host-only items are added to the widget cart
- Widget-only items are added to the host cart (via
xinfer:cart:actionadd) - Both sides have the item — the side with the larger quantity wins
cartInitMerge is enabled by default. To disable it:
<script>
window.XinferChat = { cartSync: true, cartInitMerge: false };
</script>
When disabled, the widget will still sync cart state via events, but will skip the bi-directional merge. Instead, the widget adopts the host cart as its starting state on page load (one-way copy from host to widget).
2. Build an Adapter
Your adapter bridges the widget's events to your store's cart API. It must:
- Fire
xinfer:cart:readyon mount - Respond to
xinfer:cart:requestwith your current cart - Handle
xinfer:cart:actionevents from the widget (add, remove, update, empty, sync) - Detect host cart changes and dispatch
xinfer:cart:actionwithaction: "sync"and the full items array
Key rule: The sync action is designed to be safe — when in doubt, send a sync with your full cart. The receiver compares the incoming items with its own cart and only applies changes if there's an actual difference. A skip counter or flag can further reduce unnecessary round-trips, but is not strictly required when using sync.
Events
All events are CustomEvents on window. Each includes a source field ("host" or "widget") to prevent infinite loops — only handle events from the other side.
| Event | Direction | Payload |
|---|---|---|
xinfer:cart:ready | Host -> Widget | { source: "host" } |
xinfer:cart:request | Either -> Either | { source } |
xinfer:cart:response | Either -> Either | { source, items: XinferCartItem[] } |
xinfer:cart:action | Either -> Either | { source, action, item?, items? } |
Actions
| Action | Description |
|---|---|
add | Add item to cart. Resolved via id, sku, title. |
remove | Remove item by id (variant ID) |
update | Update item quantity |
empty | Clear all items (checkout or manual clear) |
sync | Replace cart with the provided items array. See Sync Action. |
XinferCartItem
type XinferCartItem = {
id?: string; // product id
sku?: string; // fallback match key
title: string; // may be used in match
quantity: number;
unit_price: number;
currency?: string;
image_url?: string;
url?: string;
};
The widget passes whichever identifiers the AI returned — typically id, sku, title. Your adapter should resolve these to a platform-native sellable item. For Shopify, it is a product variant - GID (gid://shopify/ProductVariant/${id}). Fall back to sku and title search.
Sync Action
The sync action declares "here is my full cart — make yours match". The sender provides the entire items array; the receiver diffs it against its own cart and applies only the necessary changes (add missing items, remove extra items, update quantities that differ). If the carts already match, the receiver does nothing.
xinfer:cart:action { source, action: "sync", items: XinferCartItem[] }
When to use sync:
- Your cart framework (e.g. React's
useCart) gives you the full item list when the cart changes, but not what changed - You don't have the previous state to compute a diff
- You want a simpler integration that doesn't require diffing logic
- When in doubt —
syncis always safe to send
How the receiver handles it:
- Widget receives
syncfrom host — diffs against the widget cart and applies changes (the widget uses a PUT for efficiency since its cart is a simple JSON store) - Host receives
syncfrom widget — diffs against its current cart, then only adds missing items, removes extra items, and updates changed quantities. Do not empty the cart and re-add everything — that causes unnecessary API calls and UI flicker in frameworks like React.
Key rule: The sync action is inherently safe from infinite loops — when the carts already match, the receiver has nothing to change and generates no outbound events. A skip counter can reduce unnecessary diff work, but the protocol is safe without one.
Both the React and Vanilla JS reference adapters use sync as their primary outbound action.
Reference Adapter (React + Shopify)
Below is a complete adapter for a Next.js Shopify store. Adapt the cart API calls to your platform.
The adapter uses a server-side findItem function that resolves any combination of identifiers (id, sku, title, url) to a Shopify variant GID. This keeps resolution logic on the server where it has access to the Storefront API.
The adapter uses the sync action to notify the widget whenever the host cart changes. This avoids diffing — useCart already provides the full item list, so we just send it. A mounted ref skips the very first sync effect run — the ready → request → response handshake handles initial state, so firing a sync before listeners are registered is wasteful. A skip counter reduces unnecessary no-op syncs: before each Shopify mutation triggered by a widget event, the counter is incremented. When the sync effect sees the resulting cart change, it decrements the counter and skips dispatching. This is an optimization — the protocol is safe without it since the receiver ignores syncs that don't change anything.
"use client";
import type { CartItem } from "lib/shopify/types";
import { useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
import {
addItem,
emptyCartAction,
findItem,
removeItem,
updateItemQuantity,
} from "./cart/actions";
import { useCart } from "./cart/cart-context";
type XinferCartItem = {
id?: string; // product id
sku?: string; // fallback match key
title: string; // may be used in match
quantity: number;
unit_price: number;
currency?: string;
image_url?: string;
url?: string;
metadata?: Record<string, unknown>;
};
function dispatch(eventName: string, detail: unknown) {
window.dispatchEvent(new CustomEvent(eventName, { detail }));
}
function toXinferItem(item: CartItem): XinferCartItem {
const unitPrice =
Number(item.cost.totalAmount.amount) / Math.max(item.quantity, 1);
return {
id: item.merchandise.id,
sku: item.merchandise.sku ?? undefined,
title:
item.merchandise.product.title +
(item.merchandise.title !== "Default Title"
? ` - ${item.merchandise.title}`
: ""),
quantity: item.quantity,
unit_price: unitPrice,
currency: item.cost.totalAmount.currencyCode,
image_url: item.merchandise.product.featuredImage?.url,
url: `/products/${item.merchandise.product.handle}?variant=${item.merchandise.id}`,
};
}
export function XinferCartAdapter() {
const { cart } = useCart();
const router = useRouter();
const lines = cart?.lines ?? [];
const linesRef = useRef(lines);
// Skip counter: incremented before each Shopify mutation triggered by a
// widget action, decremented when the sync effect sees the resulting cart
// change. This prevents echoing events back for changes caused by
// processing received events.
const skipRef = useRef(0);
const mountedRef = useRef(false);
// Keep ref in sync so event handlers always read fresh data
useEffect(() => {
linesRef.current = lines;
}, [lines]);
// Sync host cart to widget whenever it changes.
// Skip the very first run — the ready event + widget request flow
// handles the initial state, so we don't need a premature sync.
useEffect(() => {
if (!mountedRef.current) {
mountedRef.current = true;
return;
}
if (skipRef.current > 0) {
skipRef.current--;
return;
}
dispatch("xinfer:cart:action", {
source: "host",
action: "sync",
items: lines.map(toXinferItem),
});
}, [lines]);
// Set up event listeners (once) and signal ready
useEffect(() => {
function onRequest(e: Event) {
const { source } = (e as CustomEvent).detail ?? {};
if (source === "widget") {
dispatch("xinfer:cart:response", {
source: "host",
items: linesRef.current.map(toXinferItem),
});
}
}
async function resolveVariant(xinferItem: XinferCartItem) {
return (
(await findItem({
id: xinferItem.id,
sku: xinferItem.sku,
title: xinferItem.title,
url: xinferItem.url,
})) ?? undefined
);
}
async function applySync(syncItems: XinferCartItem[]) {
const currentLines = linesRef.current;
// Resolve incoming items to variant IDs
const resolved: { variantId: string; quantity: number }[] = [];
for (const si of syncItems) {
const vid = await resolveVariant(si);
if (vid) resolved.push({ variantId: vid, quantity: si.quantity });
}
// Diff current cart vs incoming sync
const currentMap = new Map(
currentLines.map((l) => [l.merchandise.id, l.quantity]),
);
const syncMap = new Map(
resolved.map((r) => [r.variantId, r.quantity]),
);
// Add missing items / update changed quantities
for (const { variantId, quantity } of resolved) {
const cur = currentMap.get(variantId);
if (cur === undefined) {
await addItem(undefined, variantId);
if (quantity > 1) {
await updateItemQuantity(undefined, {
merchandiseId: variantId,
quantity,
});
}
} else if (cur !== quantity) {
await updateItemQuantity(undefined, {
merchandiseId: variantId,
quantity,
});
}
}
// Remove items not in sync
for (const line of currentLines) {
if (!syncMap.has(line.merchandise.id)) {
await removeItem(undefined, line.merchandise.id);
}
}
}
async function onAction(e: Event) {
const { action, item, items, source } = (e as CustomEvent).detail ?? {};
if (source !== "widget") return;
// Increment skip counter — the resulting cart change is caused by
// processing this event, not by the host user.
skipRef.current++;
if (action === "empty") {
await emptyCartAction();
} else if (action === "sync") {
await applySync(Array.isArray(items) ? items : []);
} else if (action === "add" && item) {
const variantId = await resolveVariant(item);
if (!variantId) { skipRef.current--; return; }
await addItem(undefined, variantId);
} else if (action === "remove" && item) {
const variantId = await resolveVariant(item);
if (!variantId) { skipRef.current--; return; }
await removeItem(undefined, variantId);
} else if (action === "update" && item) {
const variantId = await resolveVariant(item);
if (!variantId) { skipRef.current--; return; }
await updateItemQuantity(undefined, {
merchandiseId: variantId,
quantity: item.quantity ?? 0,
});
} else {
skipRef.current--;
return;
}
router.refresh();
}
window.addEventListener("xinfer:cart:request", onRequest);
window.addEventListener("xinfer:cart:action", onAction);
dispatch("xinfer:cart:ready", { source: "host" });
return () => {
window.removeEventListener("xinfer:cart:request", onRequest);
window.removeEventListener("xinfer:cart:action", onAction);
};
}, [router]);
return null;
}
The findItem server action resolves identifiers in priority order:
- ID — constructs the GID directly (
gid://shopify/ProductVariant/${id}) - SKU — searches Shopify products by SKU
- URL — extracts
?variant=param or derives product handle from the path - Title — searches Shopify products by title (least reliable)
Mount inside your cart provider so it has access to cart state:
// app/layout.tsx
<CartProvider>
<XinferCartAdapter />
{children}
</CartProvider>
Vanilla JS Adapter
If you're not using React, the same pattern works with plain JavaScript. The processingWidgetAction flag is an optional optimization that skips no-op syncs for cart changes caused by processing widget actions.
<script>
window.XinferChat = { cartSync: true };
let processingWidgetAction = false;
function dispatch(event, detail) {
window.dispatchEvent(new CustomEvent(event, { detail }));
}
function getCartItems() {
// Replace with your cart API
return getYourCartItems().map(item => ({
id: item.variantId,
sku: item.sku,
title: item.name,
quantity: item.qty,
unit_price: item.price,
image_url: item.image,
}));
}
// Send full cart to widget via sync — no diffing needed.
// Call this whenever your cart changes (e.g. after AJAX cart updates,
// MutationObserver on cart DOM, or your framework's cart events).
function syncCart() {
if (processingWidgetAction) {
processingWidgetAction = false;
return;
}
const items = getCartItems();
dispatch("xinfer:cart:action", { source: "host", action: "sync", items });
}
// Respond to widget cart requests with current host cart
window.addEventListener("xinfer:cart:request", (e) => {
if (e.detail?.source === "widget") {
dispatch("xinfer:cart:response", { source: "host", items: getCartItems() });
}
});
// Handle widget cart mutations (AI added/removed/updated items)
window.addEventListener("xinfer:cart:action", async (e) => {
const { action, item, items, source } = e.detail ?? {};
if (source !== "widget") return;
// Flag to suppress the sync for this widget-triggered change
processingWidgetAction = true;
switch (action) {
case "add":
await yourCartApi.add(item.id, item.quantity ?? 1);
break;
case "remove":
await yourCartApi.remove(item.id);
break;
case "update":
await yourCartApi.update(item.id, item.quantity);
break;
case "empty":
await yourCartApi.clear();
break;
case "sync":
await yourCartApi.clear();
for (const syncItem of items || []) {
await yourCartApi.add(syncItem.id, syncItem.quantity ?? 1);
}
break;
}
});
// Signal ready — widget will request cart via xinfer:cart:request
dispatch("xinfer:cart:ready", { source: "host" });
</script>
<script src="https://[subdomain].xinfer.ai/widget.js" async></script>
How It Works
Initial Sync
Host adapter mounts
→ fires xinfer:cart:ready { source: "host" }
→ widget hears ready, fires xinfer:cart:request { source: "widget" }
→ host responds with xinfer:cart:response { source: "host", items: [...] }
→ widget stores host cart in memory
→ init merge: aligns both carts (higher quantity wins)
Cart Request / Response
Either side fires xinfer:cart:request { source: "host"|"widget" }
→ the other side responds with xinfer:cart:response
→ widget always fetches from server (GET /api/embed/cart), never local cache
→ host responds from in-memory state
The source field identifies who is sending the request, not who should respond. Each side only handles requests from the other side:
source: "widget"→ host'sonRequestfires, host respondssource: "host"→ widget'sonRequestfires, widget responds
AI Mutates Cart
AI calls shopping_cart tool (add/remove/update)
→ widget updated server-side
→ widget fires xinfer:cart:action { source: "widget", action, item }
→ host adapter resolves item and applies the mutation
For remove actions, the widget sends { id } directly (the item is already gone from the post-removal cart). Your adapter should match by id to find the item to remove.
AI Checkout
AI calls shopping_cart "submit" → order created, cart emptied
→ widget fires xinfer:cart:action { source: "widget", action: "empty" }
→ host adapter empties its cart
Host User Mutates Cart
User adds/removes/updates item on the host site
→ adapter fires xinfer:cart:action { source: "host", action: "sync", items: [...] }
→ widget PUTs the full items array to its DB cart
The sync action sends the full cart — no diffing required. The receiver only applies changes if the items differ from its current cart, so echoing back is naturally suppressed. A skip counter can further optimize by avoiding no-op syncs.
Host Checkout
User checks out on host site → cart empties
→ adapter detects lines went from >0 to 0
→ fires xinfer:cart:action { source: "host", action: "empty" }
→ widget empties its DB cart via DELETE /api/embed/cart
Cross-Tab Sync (Socket.IO)
When a user opens full chat in a new tab or uses voice chat, cart mutations are pushed to the widget tab via Socket.IO cart-updated events. The widget tab then dispatches the same DOM events to the host adapter — no extra code needed in your adapter. All cart actions (add, remove, update, empty, checkout) are synced in real-time across tabs.
Live Demo
See cart sync in action on the Shopping Cart Demo page. It shows the event flow between host and widget in real time and serves as a playground for testing sync between the host cart and the AI agent cart.
Related Pages
- Shopping Carts - Admin guide for managing carts
- Orders - View submitted orders
- Chat Clients - Widget installation and configuration
- Integration - Get embed codes and setup