Cart Events
These events handle shopping cart interactions including viewing, updating, validating, adding, and removing items.
cart:update
Dispatched by store when the cart content changes.
import type { NubeSDK } from "@tiendanube/nube-sdk-types";
export function App(nube: NubeSDK) {
nube.on("cart:update", ({ cart }) => {
if (cart.items.length > 5) {
console.log("Purchased more than 5 different items");
}
});
}
cart:before_update
Storefront only. Dispatched by store before any cart line mutation — adding an item, editing an item's quantity, or removing an item. It lets an app confirm or cancel the operation before the cart changes.
This event is opt-in. It is only dispatched if at least one installed app enables handle_cart_before_update through config:set :
nube.send("config:set", () => ({
config: {
handle_cart_before_update: true,
},
}));
If no installed app opted in, no event is dispatched and the cart mutation runs immediately, with no added latency.
config:set replaces the whole configEach config:set rewrites all of the app's config flags, and any flag absent from the call is reset to false. A later config:set that sets, say, has_cart_validation without repeating handle_cart_before_update silently opts the app out, and cart:before_update simply stops arriving. Always send the complete set of flags your app needs.
eventPayload
| Property | Type | Description |
|---|---|---|
request_id | string | Unique identifier for this request. Must be echoed back in cart:before_update:result. |
action | "ADD" or "REMOVE" | Derived from the quantities: ADD when new_quantity > previous_quantity, REMOVE otherwise. |
item | object | The cart line about to change. See the table below. |
The item object:
| Property | Type | Description |
|---|---|---|
product_id | number or null | Identifier of the product. null when the storefront cannot resolve it. |
variant_id | number or null | Identifier of the selected variant. null when the storefront cannot resolve it. |
previous_quantity | number | Quantity of this item in the cart before the operation. |
new_quantity | number | Quantity it would have after the operation. 0 when the item is being removed. |
Editing a quantity in the cart is reported as ADD or REMOVE according to the direction of the change — there is no separate action for it.
Responding to the request
Your app must answer with cart:before_update:result within 5 seconds, echoing the request_id it received. The handshake is fail-open:
- No answer within 5 seconds — the mutation proceeds.
proceedis anything other thanfalse(including omitted) — the mutation proceeds.- Several apps opted in — one request is dispatched per opted-in app, in parallel, each with its own
request_id. A singleproceed: falsecancels the operation (cancel wins, regardless of order). Answer exactly once per request you receive.
An event that arrives before the app registers a listener for it is held and replayed to the first nube.on call for that type — but only the most recent one, and the 5 second window keeps running in the meantime. A gate request replayed to a late listener may already have timed out and proceeded, in which case the result is ignored. Register cart:before_update at the top of App(), alongside config:set, rather than behind an await.
Event flow
Approved (proceed: true):
cart:before_update— store → app, with{ action, item, request_id }.cart:before_update:result— app → store, within 5 seconds, with{ request_id, proceed: true }.cart:update— store → apps, with the already mutated cart.cart:add:successorcart:remove:success, according to the operation.
Aborted (proceed: false):
cart:before_update— store → app, with{ action, item, request_id }.cart:before_update:result— app → store, with{ request_id, proceed: false, reason? }.- The mutation is aborted. The cart is left untouched, so no
cart:update,cart:add:successorcart:remove:successis dispatched. The storefront does not lock the cart, and the add-to-cart button returns from its loading state. - From this point the app owns the outcome. It can show its own message, or run a different operation with
cart:add/cart:remove— those go through the normal flow and dispatch the usual cart events.
App-issued mutations are gated too
The gate wraps the storefront's cart primitives, not just the theme's buttons. A cart:add or cart:remove sent by an app goes through the same primitives, so it dispatches cart:before_update again. There is no bypass flag.
An app also receives the events it sends itself. Events are broadcast to every app, and the sender is not excluded, so nube.on("cart:add") fires for the app's own nube.send("cart:add") — independently of the gate re-firing.
This matters whenever an app answers proceed: false and then re-issues its own operation: without a guard, the app cancels its own re-issued add, forever.
The payload carries no origin marker and no correlation with the cart:add that produced it — request_id is generated by the storefront for each gate round, so an app-issued add and a shopper's click are indistinguishable from the event alone. Until an origin flag exists, the app has to correlate on its own:
// Expiry timestamps of the re-adds this app issued and expects to see echoed
// back, keyed by signature. One entry per in-flight re-add, so two identical
// re-adds are consumed one at a time instead of collapsing into one.
const pendingSelfAdds = new Map<
string,
{ expiresAt: number; variantId: number | null }[]
>();
// How long an unclaimed re-add stays pending. Long enough to cover the echo,
// short enough that a re-add which never arrives cannot swallow a genuine add
// later on. This window is the app's own choice, not a platform guarantee.
const SELF_ADD_TTL_MS = 3000;
const signature = (productId: number, quantity: number) =>
`${productId}:${quantity}`;
const reAddWithProperties = (
nube: NubeSDK,
productId: number,
variantId: number,
quantity: number,
properties: Record<string, unknown>,
) => {
const key = signature(productId, quantity);
const pending = pendingSelfAdds.get(key) ?? [];
pending.push({ expiresAt: Date.now() + SELF_ADD_TTL_MS, variantId });
pendingSelfAdds.set(key, pending);
nube.send("cart:add", () => ({
cart: { items: [{ product_id: productId, variant_id: variantId, quantity, properties }] },
}));
};
// Consumes one non-expired entry for this signature, if any. `variantId` is the
// variant the gate reported, used only to narrow between entries — never as the
// key itself, since it is null off the product page.
const claimSelfAdd = (key: string, variantId: number | null) => {
const now = Date.now();
const pending = (pendingSelfAdds.get(key) ?? []).filter((e) => e.expiresAt > now);
// Prefer the entry for this exact variant. With no variant reported there is
// nothing to narrow on, so fall back to the oldest entry.
const index = variantId === null
? (pending.length > 0 ? 0 : -1)
: pending.findIndex((e) => e.variantId === variantId);
if (index !== -1) pending.splice(index, 1);
if (pending.length > 0) pendingSelfAdds.set(key, pending);
else pendingSelfAdds.delete(key);
return index !== -1;
};
// Inside the cart:before_update handler, before any other decision:
// With no product_id there is nothing to correlate on — never build a key from
// a null. Let the mutation through untagged instead of risking a false match.
if (item.product_id === null) {
respond(true);
return;
}
const key = signature(item.product_id, item.new_quantity - item.previous_quantity);
if (claimSelfAdd(key, item.variant_id)) {
respond(true); // our own add — let it through
return;
}
Five things to know when building that correlation:
- Key on
product_idand the quantity delta; usevariant_idonly to narrow.variant_idcannot be part of the key, because the storefront resolves it from the variant selected in the product page's own inputs and reportsnullanywhere that container is absent — the cart page, a grid, a quickshop. But where it is non-null it is the variant the shopper selected, which is the variant the app re-adds, so it is the right discriminator between several pending entries for the same product and delta. product_idis nullable, and a null cannot be correlated. When the storefront cannot resolve it, every unresolvable add collapses onto the same key, so a signature built from it would match the wrong operation. Tagging is not supported for those adds: answerproceed: trueand leave the line untagged. In practice an add issued throughcart:addcarries theproduct_idthe app itself passed, so this is the rare path — but the guard is what keeps a null from ever reaching a key.- Pending entries must expire. A re-add that never produces a gate event — it failed, another opted-in app cancelled it, or the shopper navigated away — otherwise leaves its signature pending forever, and the next genuine add with the same product and delta is waved through untagged. That is the same silent discount loss the guard exists to prevent.
- The signature is not unique. Product and delta alone collide across variants of the same product, which is why the variant narrowing above matters — and even with it, collisions remain possible wherever the gate reports no variant. See the limits below.
previous_quantityaggregates every line of that variant, regardless of each line'sproperties. Once a tagged line exists alongside an untagged one for the same variant,new_quantity - previous_quantityis the delta against the combined quantity, not against the line the shopper touched.
Correlating on product and quantity within a short window — SELF_ADD_TTL_MS above, 3 seconds — covers the ordinary interaction, bumping the stepper and adding again. Two cases it cannot resolve:
- A rapid double-click of the same variant and quantity is indistinguishable from the echo of the app's own add: one of the two is consumed as the echo, so that line goes through untagged.
- A different variant of the same product, added within the window, where the gate reports no
variant_id— off the product page — collides on the same signature. The shopper's add consumes the pending entry and is answeredproceed: trueuntagged, and the app's own re-add then finds nothing pending and is treated as a shopper add. On the product page the variant narrowing prevents this; off it there is nothing left to narrow on.
Both failures cost a tag on one line. Prefer them over the opposite failure — treating an echo as a shopper add loops cancel/re-add indefinitely. Nothing in the payload can close the remaining gap; only a platform-provided origin flag or correlation id would.
Tagging a cart line with properties
cart:before_update has no way to attach data to the line being added. To tag a line, cancel the native operation and re-issue it yourself with cart:add , which accepts properties:
- Answer
cart:before_update:resultwithproceed: falseand areason. - Send
cart:addwithproperties, andquantityset tonew_quantity - previous_quantity— the delta, not the absolute quantity, so a shopper who already holds two and asks for one more ends up with three. - Guard the resulting
cart:before_updatefor that add, as shown above.
The properties you set persist through checkout and onto the order.
A tagged add never merges into an untagged line for the same variant. The storefront merges an incoming add into an existing line only when the variant matches and the two property sets are exactly equal, so:
- Adding a tagged item on top of an untagged line for the same variant creates a second, separate line. Neither line's
propertiesare dropped, and the tag is never spread over a quantity that did not earn it. - Re-adding the same variant with the same
propertiesmerges into the line that already carries them, increasing its quantity.
properties surviveproperties reach the cart through the storefront's add-to-cart form. When the current page already exposes a quantity input for that product — the cart page itself — the storefront takes a quantity-change path instead, which carries no properties. Issue tagging re-adds from the product page.
Example
The app below intercepts every cart mutation and asks the shopper to confirm it, using a UI slot on the product page.
import { Column, Button, Text, Row } from "@tiendanube/nube-sdk-jsx";
import type { NubeSDK } from "@tiendanube/nube-sdk-types";
import { theme, styled } from "@tiendanube/nube-sdk-ui";
const Container = styled(Column)`
padding-bottom: 16px;
gap: 8px;
`;
const Title = styled(Text)`
font-size: ${theme.typography.lg.fontSize};
color: ${theme.color.success.medium};
`;
export function App(nube: NubeSDK) {
// Without this flag the storefront never dispatches cart:before_update
nube.send("config:set", () => ({
config: {
handle_cart_before_update: true,
},
}));
nube.on("cart:before_update", (state) => {
const payload = state.eventPayload as
| {
request_id: string;
action: "ADD" | "REMOVE";
item: {
product_id: number | null;
variant_id: number | null;
previous_quantity: number;
new_quantity: number;
};
}
| undefined;
if (!payload?.request_id) return;
const { request_id, action, item } = payload;
const respond = (proceed: boolean, reason?: string) => {
nube.send("cart:before_update:result", () => ({
eventPayload: { request_id, proceed, reason },
}));
nube.clearSlot("before_add_to_cart_pdp");
};
nube.render("before_add_to_cart_pdp", () => (
<Container>
<Title>
{action === "ADD"
? `Add ${item.new_quantity - item.previous_quantity} unit(s)?`
: "Remove this item from the cart?"}
</Title>
<Row gap={2}>
<Button onClick={() => respond(true)}>CONFIRM</Button>
<Button onClick={() => respond(false, "user_cancelled")}>
CANCEL
</Button>
</Row>
</Container>
));
});
}
- Open any product page with your app running in local mode (see DevTools ).
- Add the product to the cart.
- The CONFIRM and CANCEL buttons appear in the
before_add_to_cart_pdpslot.
Use the Events tab of the DevTools to follow the whole flow and confirm which events are dispatched in each case.
cart:before_update:result
Storefront only. Dispatched by app to answer a cart:before_update request. Requires handle_cart_before_update: true in the script configuration — without it the app never receives a request to answer.
eventPayload
| Property | Type | Required | Description |
|---|---|---|---|
request_id | string | Yes | The request_id received in cart:before_update. Results without it are ignored. |
proceed | boolean | Yes | true lets the mutation continue; false aborts it. Any value other than false is treated as "proceed". |
reason | string | No | Short code explaining the verdict (e.g. "user_cancelled"). Used for diagnostics only, never shown to the shopper. |
import type { NubeSDK } from "@tiendanube/nube-sdk-types";
export function App(nube: NubeSDK) {
nube.send("config:set", () => ({
config: {
handle_cart_before_update: true,
},
}));
nube.on("cart:before_update", (state) => {
const { request_id, item } = (state.eventPayload ?? {}) as {
request_id?: string;
item?: { new_quantity: number };
};
if (!request_id) return;
// Block carts with more than 10 units of the same item
const proceed = (item?.new_quantity ?? 0) <= 10;
nube.send("cart:before_update:result", () => ({
eventPayload: {
request_id,
proceed,
reason: proceed ? undefined : "max_quantity_exceeded",
},
}));
});
}
Results carrying an unknown request_id — or one already resolved, for example after the 5 second window elapsed — are ignored.
cart:view
Storefront only. Dispatched by store when the user views their shopping cart. Use this to run logic when the cart page is displayed.
import type { NubeSDK } from "@tiendanube/nube-sdk-types";
export function App(nube: NubeSDK) {
nube.on("cart:view", (state) => {
console.log("User is viewing cart with", state.cart.items.length, "items");
});
}
cart:open
Storefront only. Dispatched by app to open the store's cart drawer (side cart). Use it to bring the shopper to the cart without a page navigation — for example right after adding an item, or from a button rendered in a UI slot.
This event takes no payload, so nube.send needs no second argument:
import { Button } from "@tiendanube/nube-sdk-jsx";
import type { NubeSDK } from "@tiendanube/nube-sdk-types";
export function App(nube: NubeSDK) {
nube.render("before_main_content", () => (
<Button
onClick={() => {
nube.send("cart:open");
}}
>
OPEN CART
</Button>
));
}
A common pattern is to open the cart as soon as an item is added, so the shopper sees the result of the action:
import type { NubeSDK } from "@tiendanube/nube-sdk-types";
export function App(nube: NubeSDK) {
nube.on("cart:add:success", () => {
nube.send("cart:open");
});
}
Behavior
The event opens whatever cart drawer the current theme provides — a modal cart on newer themes, or the legacy ajax cart panel on older ones. It is a no-op (nothing happens, no error) when there is no drawer to open:
- The theme has no cart drawer, or the ajax cart is disabled in the store.
- The shopper is on the cart page itself, where the cart is the page content and not a drawer.
- The drawer is already open.
On themes with a modal cart, opening the drawer also dispatches cart:view , so listeners on that event run as well. Sending cart:open does not change the cart content, so it never dispatches cart:update.
Run your app in local mode (see DevTools ), open any storefront page and trigger the send. Use the Events tab to confirm that cart:open was dispatched.
cart:validate
Checkout only. Dispatched by app to signal if the content of the cart is valid or not. Requires has_cart_validation: true in the script configuration to work, otherwise cart validation events are ignored.
import type { NubeSDK } from "@tiendanube/nube-sdk-types";
export function App(nube: NubeSDK) {
// Tell NubeSDK that this script wants to validate the content of the cart
nube.send("config:set", () => ({
config: {
has_cart_validation: true,
},
}));
nube.on("cart:update", ({ cart }) => {
if (cart.items.length < 5) {
nube.send("cart:validate", () => ({
cart: {
validation: {
status: "fail",
reason: "Cart must have at least 5 items!",
},
},
}));
} else {
nube.send("cart:validate", () => ({
cart: {
validation: {
status: "success",
},
},
}));
}
});
}
cart:add
Dispatched by app to add an item to the cart.
Each item in the items array supports the following fields:
| Property | Type | Required | Description |
|---|---|---|---|
variant_id | number | Yes | Unique identifier for the product variant to add. |
product_id | number | Yes | Unique identifier for the product. |
quantity | number | Yes | Quantity to add. |
properties | Array<unknown> or Record<string, unknown> | No | Custom properties for the item (e.g. personalization, gift messages). |
properties is how an app tags a cart line: the values persist through checkout and onto the order, and a line with properties is kept separate from a line for the same variant with a different property set. To tag a line the shopper adds through the theme's own buy button, cancel it and re-issue it here — see Tagging a cart line with properties .
An item added by an app is gated like any other cart mutation, so this event dispatches cart:before_update to every opted-in app — including the one that sent it.
import type { NubeSDK } from "@tiendanube/nube-sdk-types";
export function App(nube: NubeSDK) {
nube.send("cart:add", () => ({
cart: {
items: [
{
// Use real variant_id and product_id from your store
// (e.g. from state.location.page.data on a product page)
variant_id: 123,
product_id: 321,
quantity: 1,
properties: {
gift_message: "Happy Birthday!",
},
},
],
},
}));
}
cart:add:success
Dispatched by store when an item is successfully added to the cart.
eventPayload
This event includes state.eventPayload with details about the item(s) that were added. The payload shape depends on the context:
- Storefront: a single cart item object.
- Checkout: an array of cart item objects (one per item successfully added).
Each cart item in the payload has the following properties:
| Property | Type | Description |
|---|---|---|
id | number | Unique identifier for the product instance in the cart. |
name | string | Name of the product. |
price | string | Price in string format. |
quantity | number | Quantity added. |
free_shipping | boolean | Whether the product qualifies for free shipping. |
product_id | number | Unique identifier for the product. |
variant_id | number | Unique identifier for the selected product variant. |
thumbnail | string | URL of the product's thumbnail image. |
variant_values | string | Variant details (e.g. selected attributes). |
sku | string or null | SKU (Stock Keeping Unit) for the product variant. |
properties | Array<unknown> or Record<string, unknown> | Additional product properties. |
url | string | URL of the product's page. |
is_ahora_12_eligible | boolean | Whether the product is eligible for Ahora 12 financing. |
import type { NubeSDK } from "@tiendanube/nube-sdk-types";
export function App(nube: NubeSDK) {
nube.on("cart:add:success", (state) => {
const payload = state.eventPayload;
// In storefront, payload is a single item object
// In checkout, payload is an array of item objects
const item = Array.isArray(payload) ? payload[0] : payload;
console.log("Item added to cart:", item?.variant_id, item?.name);
});
}
cart:add:fail
Dispatched by store when there's a failure in adding an item to the cart.
This event does not include eventPayload. The state.eventPayload will be null.
import type { NubeSDK } from "@tiendanube/nube-sdk-types";
export function App(nube: NubeSDK) {
nube.on("cart:add:fail", (state) => {
console.log("Failed to add item to cart");
});
}
cart:remove
Dispatched by app to remove an item from the cart.
import type { NubeSDK } from "@tiendanube/nube-sdk-types";
export function App(nube: NubeSDK) {
nube.send("cart:remove", () => ({
cart: {
items: [
{
// Use real variant_id and product_id from the item to remove (e.g. from state.cart.items)
variant_id: 123,
product_id: 321,
quantity: 1,
},
],
},
}));
}
cart:remove:success
Dispatched by store when an item is successfully removed from the cart. The listener receives the updated state; state.cart.items reflects the removal.
eventPayload
This event includes state.eventPayload with details about the item(s) that were removed. The payload shape depends on the context:
- Storefront: a single cart item object.
- Checkout: an array of cart item objects (one per item successfully removed).
The cart item shape is the same as cart:add:success .
import type { NubeSDK } from "@tiendanube/nube-sdk-types";
export function App(nube: NubeSDK) {
nube.on("cart:remove:success", (state) => {
const payload = state.eventPayload;
const item = Array.isArray(payload) ? payload[0] : payload;
console.log(
"Item removed:", item?.variant_id,
"— cart now has", state.cart.items.length, "items",
);
});
}
cart:remove:fail
Dispatched by store when there's a failure in removing an item from the cart.
This event does not include eventPayload. The state.eventPayload will be null.
import type { NubeSDK } from "@tiendanube/nube-sdk-types";
export function App(nube: NubeSDK) {
nube.on("cart:remove:fail", (state) => {
console.log("Failed to remove item from cart");
});
}
Help us improve NubeSDK
Found an issue or have a suggestion? Let us know on GitHub.