Saltar al contenido principal

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.

Example
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 :

Opting in
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 config

Each 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

PropertyTypeDescription
request_idstringUnique 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.
itemobjectThe cart line about to change. See the table below.

The item object:

PropertyTypeDescription
product_idnumber or nullIdentifier of the product. null when the storefront cannot resolve it.
variant_idnumber or nullIdentifier of the selected variant. null when the storefront cannot resolve it.
previous_quantitynumberQuantity of this item in the cart before the operation.
new_quantitynumberQuantity it would have after the operation. 0 when the item is being removed.
Quantity edits

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.
  • proceed is anything other than false (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 single proceed: false cancels the operation (cancel wins, regardless of order). Answer exactly once per request you receive.
Register the listener early

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):

  1. cart:before_update — store → app, with { action, item, request_id }.
  2. cart:before_update:result — app → store, within 5 seconds, with { request_id, proceed: true }.
  3. cart:update — store → apps, with the already mutated cart.
  4. cart:add:success or cart:remove:success, according to the operation.

Aborted (proceed: false):

  1. cart:before_update — store → app, with { action, item, request_id }.
  2. cart:before_update:result — app → store, with { request_id, proceed: false, reason? }.
  3. The mutation is aborted. The cart is left untouched, so no cart:update, cart:add:success or cart:remove:success is dispatched. The storefront does not lock the cart, and the add-to-cart button returns from its loading state.
  4. 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:

Guarding against your own re-add
// 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_id and the quantity delta; use variant_id only to narrow. variant_id cannot be part of the key, because the storefront resolves it from the variant selected in the product page's own inputs and reports null anywhere 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_id is 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: answer proceed: true and leave the line untagged. In practice an add issued through cart:add carries the product_id the 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_quantity aggregates every line of that variant, regardless of each line's properties. Once a tagged line exists alongside an untagged one for the same variant, new_quantity - previous_quantity is the delta against the combined quantity, not against the line the shopper touched.
Known limits

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 answered proceed: true untagged, 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:

  1. Answer cart:before_update:result with proceed: false and a reason.
  2. Send cart:add with properties, and quantity set to new_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.
  3. Guard the resulting cart:before_update for 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 properties are dropped, and the tag is never spread over a quantity that did not earn it.
  • Re-adding the same variant with the same properties merges into the line that already carries them, increasing its quantity.
Where properties survive

properties 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.

Confirming a cart mutation
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>
));
});
}
Testing it
  1. Open any product page with your app running in local mode (see DevTools ).
  2. Add the product to the cart.
  3. The CONFIRM and CANCEL buttons appear in the before_add_to_cart_pdp slot.

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

PropertyTypeRequiredDescription
request_idstringYesThe request_id received in cart:before_update. Results without it are ignored.
proceedbooleanYestrue lets the mutation continue; false aborts it. Any value other than false is treated as "proceed".
reasonstringNoShort code explaining the verdict (e.g. "user_cancelled"). Used for diagnostics only, never shown to the shopper.
Example
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",
},
}));
});
}
nota

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.

Example
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:

Example
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:

Opening the cart after adding an item
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.
nota

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.

Testing it

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.

Example
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:

PropertyTypeRequiredDescription
variant_idnumberYesUnique identifier for the product variant to add.
product_idnumberYesUnique identifier for the product.
quantitynumberYesQuantity to add.
propertiesArray<unknown> or Record<string, unknown>NoCustom 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 .

nota

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.

Example
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:

PropertyTypeDescription
idnumberUnique identifier for the product instance in the cart.
namestringName of the product.
pricestringPrice in string format.
quantitynumberQuantity added.
free_shippingbooleanWhether the product qualifies for free shipping.
product_idnumberUnique identifier for the product.
variant_idnumberUnique identifier for the selected product variant.
thumbnailstringURL of the product's thumbnail image.
variant_valuesstringVariant details (e.g. selected attributes).
skustring or nullSKU (Stock Keeping Unit) for the product variant.
propertiesArray<unknown> or Record<string, unknown>Additional product properties.
urlstringURL of the product's page.
is_ahora_12_eligiblebooleanWhether the product is eligible for Ahora 12 financing.
Example
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.

nota

This event does not include eventPayload. The state.eventPayload will be null.

Example
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.

Example
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 .

Example
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.

nota

This event does not include eventPayload. The state.eventPayload will be null.

Example
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.