# Events

> The events Smootify sends when something happens on the page, how to listen with Smootify.on and once, which event to use for which task, and the ones sent on an element instead of the document.

Status: Ready (Smootify 2.0)  
URL: https://www.smootify.io/docs/developers/events  
Updated: 2026-10-10

Smootify sends an event each time something happens: the cart changes, a product loads, the visitor logs in or changes country. Your script listens and reacts. An event only tells what happened: what your handler returns is ignored. To change what Smootify does, use a [filter](https://www.smootify.io/docs/developers/filters). Each event, with its payload, is also in the Reference under [API](https://www.smootify.io/docs/reference/api).

## Listen to an event

```js
window.Smootify = window.Smootify || [];
window.Smootify.push((Smootify) => {
  const stop = Smootify.on("added_to_cart", ({ lines, cart }) => {
    console.log(lines.length, cart.totalQuantity);
  });
  Smootify.once("initiate_checkout", ({ cart }) => console.log(cart.id));
  // later: stop();
});
```

- **The name** can be written with or without its `smootify:` prefix: `"cart_updated"` or `"smootify:cart_updated"`.
- **`on` and `once` return a function** that stops listening. `once` stops by itself after the first event.
- **`{ replay: true }`**, as the third argument of `on`, also calls the handler at once with the last payload of an event that already happened.
- **Every event is also a DOM event**, with the payload in `event.detail`: `document.addEventListener("smootify:cart_updated", (event) => console.log(event.detail))`. Four of them are sent on an element instead of `document`: see [Events sent on an element](#events-sent-on-an-element).
- **Listening to `consent_changed`** is enough to make Smootify load Shopify’s consent script.

Add your listeners in a `push` function: they then get every event from the first `product_loaded` and `cart_updated` on. See [Run your scripts with Smootify](https://www.smootify.io/docs/developers/scripts).

## Which event for what

- **Start your code once everything is loaded**: `loaded`, with `{ replay: true }`.
- **Keep your own cart counter or total in sync**: `cart_updated`. It also fires when the cart first loads.
- **Track an addition**: `added_to_cart`. For the checkout: `initiate_checkout`.
- **React to the chosen variant**: `variant_changed`.
- **Know whether the visitor is logged in**: `user_auth_change` with `{ replay: true }`, or `await Smootify.getCustomer()`.
- **Start analytics after consent**: `consent_changed`.

## The events

### Loading and products

| Event | When | Payload |
|---|---|---|
| `loaded` | Smootify is loaded: the session, the market and the cart are known. | none |
| `product_loaded` | A product loaded, once per product, after loaded. | `product`: the product, or null when Shopify does not return it: not published on the Headless channel, or not sold in the market; `id`: the id of the product asked for |
| `product_expired_load` | A product shown from the cache has changed on Shopify. | `product`: the fresh product from Shopify; `id`: its id |
| `loadedAdditionalVariants` | The page's product has more than 250 variants: it now carries all of them. Once, after its product_loaded. | `product`: the product with every variant |
| `variant_changed` | A product's variant changed, not at the first render. | `element`: the smootify-product on the page; `product`: its product; `variant`: the new variant, undefined for a combination that does not exist |
| `changed_option` | The visitor picked an option value. Sent on `smootify-add-to-cart`, not on document. | `optionChangedIndex`: the position of the option that changed; `selectedValue`: the value picked; `variant`: the variant it leads to, when it exists; `variants`: the product's variants; `product`: the product |

In `product_loaded`, `product` is `null` when Shopify does not return the product: it is not published to the store’s sales channel, or not sold in the visitor’s market. In `variant_changed`, `element` is the `smootify-product` on the page and `variant` is `undefined` for a combination that does not exist.

### Cart

| Event | When | Payload |
|---|---|---|
| `cart_updated` | Every change of the cart, the first load included. | `cart`: the whole cart: its lines, quantities, totals and discounts |
| `added_to_cart` | Lines were added, after Shopify answered. | `lines`: the lines added: the variant (merchandiseId), the quantity, the line attributes and the selling plan; `cart`: the cart after the addition; `goToCheckout`: true with Buy now |
| `removed_from_cart` | Lines were removed. | `lineIds`: the ids of the cart lines removed |
| `errors` | Shopify refused a cart operation, or a filter threw an error. | `errors`: each error with its message, and the field and code when Shopify gives them |
| `initiate_checkout` | The visitor leaves for checkout. | `cart`: the cart the visitor checks out |
| `cart_opened` | A mini cart opened. `cart_closed` is sent when it closes. | `element`: the smootify-cart that opened or closed |

`cart_opened` and `cart_closed` fire for every kind of mini cart (dialog, details, Webflow Dropdown, popover or your own trigger), never for a cart page. `element` is the `smootify-cart`.

### Customer, market and consent

| Event | When | Payload |
|---|---|---|
| `user_auth_change` | The session is known, or changes. | `customer`: the logged-in customer, or null for a guest |
| `changed_country` | The visitor's country changed. | `country`: the new country code, such as IT; `oldCountry`: the country before |
| `consent_changed` | The consent is known on the page, then at every change. | `consent`: loaded (Shopify's script answered), required (the banner shows), allowed (true or false per category), answers, region, saleOfDataRegion and interacted |
| `created_address` | A create-address form saved an address. | `address`: the address saved |

The first `user_auth_change` goes out before the `push` functions run. Listen with `{ replay: true }` to get it.

### Wishlist

| Event | When | Payload |
|---|---|---|
| `wishlist:changed_product_state` | The visitor added (active: true) or removed a product. | `node`: the wishlist-toggle; `product`: the product added or removed; `active`: true when added, false when removed |
| `wishlist:changed_product` | A wishlist-toggle got its product. | `node`: the wishlist-toggle; `product`: the product it now follows |
| `wishlist:changed_variant_state` | The visitor added or removed a variant. | `node`: the wishlist-toggle; `variant`: the variant added or removed; `active`: true when added, false when removed |
| `wishlist:changed_variant` | The toggle follows another variant. | `node`: the wishlist-toggle; `variant`: the variant it now follows |

`node` is the `wishlist-toggle`. More in [Wishlist](https://www.smootify.io/docs/features/wishlist).

### Search and discovery

| Event | When | Payload |
|---|---|---|
| `filter_initial_change` | A filter was applied from selected-value or the page URL, before the first search. Sent on `smootify-search-discovery`, not on document. | `name`: the label of the filter; `value`: the value applied, or the list of values; undefined when none |
| `filter_changed` | The visitor changed a filter (name: "query" for the search text). Sent on `smootify-search-discovery`, not on document. | `name`: the label of the filter, or query for the search text; `value`: the value chosen, or the list of values |
| `sort_changed` | The visitor changed the sorting. Sent on `smootify-search-discovery`, not on document. | `name`: always sort; `value`: the sort option chosen |

These three are sent on an element: see below.

## Events sent on an element

Four events are not sent on `document`, and do not bubble up to it:

- `changed_option` is sent on the `smootify-add-to-cart` form around the picker, or on the picker when it is outside the form;
- `filter_initial_change`, `filter_changed` and `sort_changed` are sent on the `smootify-search-discovery` block, or on the `smootify-search-page` block.

Where you listen decides whether your handler runs:

| Where you listen | Handler called | Why |
|---|---|---|
| `Smootify.on(…)`, or a plain listener on `document` | no | the event is sent on the element and does not bubble up to `document` |
| `document.addEventListener(…, true)` | yes | the capture phase passes `document` on its way down to the element |
| a listener on the element itself | yes | the event is sent there |

The two ways that work, in code:

```js
document.addEventListener(
  "smootify:changed_option",
  (event) => console.log(event.detail.optionChangedIndex, event.detail.selectedValue),
  true,
);

document
  .querySelector("smootify-search-discovery")
  ?.addEventListener("smootify:filter_changed", (event) => console.log(event.detail.name, event.detail.value));
```

To react to a new variant, `variant_changed` is simpler: it is sent on `document` and carries the product element.

## Other DOM events

Smootify also sends a few DOM events kept from 1.x. They are not in `smootify.d.ts`:

- **`productLoaded` and `changeVariant`** on each `smootify-product` element, with the product or the variant in `event.detail`. They do not bubble: listen on the element. `changeVariant` also fires at the first render.
- **`changePlan`** on each `smootify-product` element, when the visitor picks a selling plan or goes back to the one-time purchase. `event.detail` has `plan` (`null` for the one-time purchase) and `variant`. It does not bubble: listen on the element.
- **`addon:state_change`** on `document` and on the `smootify-add-to-cart` form, each time an [add-on](https://www.smootify.io/docs/features/add-ons) is picked or dropped. `event.detail` has `wrapper` (the add-on element), `product`, `variant` and `included`.
- **`variantsRendered`** on `document.body`, each time a [variant card](https://www.smootify.io/docs/build/variants-list) (`smootify-variant`) is filled.
- **`smootify:productImagesModuleLoaded`** on `document`, each time a deprecated `product="images"` element draws its images. The `product="gallery"` element does not send it. It does not mean every image has finished downloading.
- **`smootify:magic_box_change`** on `document.body`, once the box has loaded and after each change: see [Example: a Magic Box title that follows its contents](#example-a-magic-box-title-that-follows-its-contents).
- **`smootify:price_render`** on `document`, each time a `smootify-price` writes a new amount. `event.detail.element` is the price element.
- **`smootify.devMetafield` and `smootify.renderedMetafields`** on `document`, for metafields: see [Render a metafield yourself](https://www.smootify.io/docs/developers/custom-metafields).

## Example: a Magic Box title that follows its contents

`smootify:magic_box_change` carries `element` (the `smootify-magic-box`), `currentQuantity` (how many products the box holds) and `cartItems`, one `{ lineItem, variant }` per product. The box element has two methods: `modifyBoxTitle(title)` and `modifyBoxImage(url)`, which accepts only an image served by Shopify’s CDN (upload it under **Content > Files**). The new title and image are the ones the box line takes in the cart and at checkout.

```js
document.body.addEventListener("smootify:magic_box_change", (event) => {
  const { element, currentQuantity } = event.detail;
  element.modifyBoxTitle(`Box of ${currentQuantity} wines`);
  element.modifyBoxImage(
    currentQuantity > 3 ? "https://cdn.shopify.com/…/box-large.jpg" : "https://cdn.shopify.com/…/box-small.jpg",
  );
});
```

Add the listener before the box loads, in the page’s head code, so it also gets the first event.

## Example: the store credit a cart earns

When a Shopify Flow workflow gives store credit on paid orders, like [Store credit on a paid order](https://www.smootify.io/flows/store-credit) (5% of the subtotal from 50 up), the cart can say what this order will earn. Smootify has no attribute for it, because the rule is yours: the percentage, the minimum and whether to show it at all. A few lines on `cart_updated` do it.

Put the message next to the cart’s subtotal, outside the line template, and hide it in Webflow:

```html
<p data-credit-estimate hidden>This order earns you <span data-credit-amount></span> of store credit.</p>
```

Then add the script, with the same numbers as your workflow:

```js
window.Smootify = window.Smootify || [];
window.Smootify.push((Smootify) => {
  const RATE = 0.05; // the share your workflow credits
  const MINIMUM = 50; // its minimum subtotal
  const SHOP_CURRENCY = "EUR"; // the currency of that minimum

  Smootify.on("cart_updated", (cart) => {
    const { amount, currencyCode } = cart.cost.subtotalAmount;
    const subtotal = Number(amount);
    const earns = currencyCode === SHOP_CURRENCY && subtotal >= MINIMUM;
    document.querySelectorAll("[data-credit-estimate]").forEach((message) => {
      message.hidden = !earns;
      const value = message.querySelector("[data-credit-amount]");
      if (earns && value) value.textContent = Smootify.formatMoney((subtotal * RATE).toFixed(2));
    });
  }, { replay: true });
});
```

- **It is an estimate.** The credit is what the workflow gives when the order is paid. Change the numbers in both places, or the cart promises what the workflow does not give.
- **One currency.** The minimum is in the shop’s currency, so the message shows only when the visitor’s cart is in that currency. In other markets it stays hidden.
- **`{ replay: true }`** shows it at once for the cart that already loaded.

## Where people get stuck

> [!STUCK]
> - **`Smootify.on("changed_option")` never runs.** The event is sent on the form or the picker, not on `document`. Listen in the capture phase, or use `variant_changed`.
> - **`Smootify.on("filter_changed")` never runs.** The same: the event is sent on the search and discovery block. Listen on that element.
> - **The first `user_auth_change` is missed.** It goes out before the `push` functions run. Add `{ replay: true }`.
> - **A script added by a tag manager never hears `loaded`.** The event fired before the script arrived. Add `{ replay: true }`.
> - **Returning a value from a handler changes nothing.** Events only report. To change the lines, the checkout address or a draft order, use a filter.
> - **A wishlist handler runs twice.** It listens to both the 1.x name (`smootify_wishlist:…`) and the 2.0 name, and Smootify sends both. Keep the 2.0 name.

## Coming from 1.x

The names and the payloads of the events are the same as in 1.x, with these changes:

- **Wishlist events are renamed** `smootify:wishlist:…`, like every other event. The 1.x names (`smootify_wishlist:changed_product`, `changed_product_state`, `changed_variant`, `changed_variant_state`) are still sent, with the same payload, until 3.0.
- **New in 2.0**: `cart_opened`, `cart_closed`, `consent_changed` and `variant_changed`.
- **Now sent**: `created_address` and the two `wishlist:changed_variant` events, which 1.x declared and never sent.
- **One event per thing that happened.** `product_loaded` goes out once per product, `changed_option` only when the visitor picks a value, `sort_changed` not for a `selected-value` preselection, and `smootify:price_render` only when the amount changes. A script that counted events to detect the end of loading should listen to `loaded`.
- **`smootify:color_patterns` is no longer sent**: 2.0 does not load the shop’s colour patterns.
- **`Smootify.on` and `once`** are new: in 1.x, scripts used `document.addEventListener`, which still works.
