> For the complete documentation index, see [llms.txt](https://helps.ptengine.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://helps.ptengine.com/en/start-guide/othertag/tag-shopify.md).

# Installing Tags in Shopify

## Installing Tags in Shopify

> For Shopify app integration setup, see also [Shopify Integration](https://gitlab.com/PtmindDev/ptx/ptengine-helps/-/tree/main/DOCS/en/start-guide/integrations/shopify.md).

## Basic Tag Installation

### 1. Find the theme.liquid template

In the Shopify admin dashboard, select "Online Store" → "Themes", then select "Edit code" from the "…" button

### 2. Install the Ptengine basic tag under

Please paste the Ptengine basic tag in the red box area on the right side of the image below.

#### **Example of Basic Tag**

```js
<!-- Ptengine Tag -->
<script src="https://js.ptengine.com/xxxxxxxx.js"></script>
<!-- End Ptengine Tag -->
```

> **DANGER**
>
> **⚠️ Caution**
>
> The xxxxxxxx part above is your project ID, which differs for each Ptengine project.
>
> Please replace it with your own project's ID.
>
> For more details, please refer to [this article](/en/start-guide/othertag/tag-shopify.md).

#### **How to Obtain the Ptengine Basic Tag**

## Event Installation

Simply installing the basic tag above is not enough to aggregate behavioral data such as **purchase completion and cart addition**, which are common on e-commerce sites.

Therefore, if you also want to check event data and conversion data on Ptengine, we also recommend setting up events.

### Implementing Event Collection Tags

#### 1. Select "Settings" in the Shopify Admin Dashboard

#### 2. Select "Customer events"

#### 3. Set a Pixel Name

Click the black "Add custom pixel" button in the top right, and set the pixel name in the popup. (In the figure below, we have set it to "pt tracking on checkouts" as an example)

Then click "Add pixel".

#### 4. Save

Paste the code below in the Code section, and save by clicking "save" in the top right.

> **DANGER**
>
> **⚠️ Caution**
>
> **Be sure to replace** `const sid` **with your own Ptengine project ID!!!**

```js
// Code to paste
// Be sure to replace sid with your own profile ID
const sid = "Enter your profile ID";
const area = "jp";

// The sandbox path shape is determined by the web pixel's runtimeContext
const SANDBOX_RE =
  /\/(?:wpm|web-pixels)@[^/]+\/(?:app\/|custom\/)?web-pixel-[^/]+\/sandbox\/modern/;

let ptenginePromise = null;

/**
 * Get the real storefront page URL.
 * The sandbox URL carries the page path, so stripping the sandbox segment recovers
 * the real page URL. The event context is more reliable, though, so read from
 * event.context first and use the regex only as a fallback.
 */
function getPageUrl(event) {
  return (
    event?.context?.document?.location?.href ||
    event?.context?.window?.location?.href ||
    window.location.href.replace(SANDBOX_RE, "")
  );
}

// sid / area are read from the module constants and no longer passed as parameters
function loadPtengineScript(pageUrl) {
  // Resolve immediately if ptengine already exists
  if (window.ptengine) return Promise.resolve();
  // A load is already in flight: reuse it, do not inject again
  if (ptenginePromise) return ptenginePromise;

  ptenginePromise = new Promise((resolve, reject) => {
    // Send with the original URL. The SDK splits setPVTag on ',' (and strictly reads
    // the 3rd segment to detect "replace"), so a comma inside the URL shifts every
    // segment and must be escaped.
    window._pt_sp_2 = window._pt_sp_2 || [];
    window._pt_sp_2.push("setAccount," + sid);
    window._pt_sp_2.push(
      "setPVTag," + String(pageUrl).replace(/,/g, "%2C") + ",replace",
    );

    // Structural limitation: the SDK consumes setPVTag only on first load, so every
    // later event in the same sandbox lifetime is attributed to the first event's URL.
    // On a multi-page Shopify store each navigation rebuilds the sandbox, so there is
    // no impact. With SPA-style theme navigation, or a single-page checkout that walks
    // through several steps, later events attach to the first URL; precise attribution
    // then relies on the url field in the event properties.
    // Dynamically load the ptengine script
    const script = document.createElement("script");
    // Check whether this is a checkout page (must use the real page URL; the sandbox
    // URL does not contain /checkouts/)
    const isCheckoutPage =
      /\/checkouts?\//.test(pageUrl) || /thank[_-]you/.test(pageUrl);
    const sandboxQuery = isCheckoutPage ? "" : "?sandbox"; // no '?sandbox' on checkout pages
    script.src =
      "https://js.ptengine." + area + "/" + sid + ".js" + sandboxQuery;
    script.onload = () => resolve(); // loaded
    script.onerror = () => {
      ptenginePromise = null; // allow later events to retry
      reject(new Error("Script loading failed")); // failed
    };
    document.head.appendChild(script);
  });

  return ptenginePromise;
}

/**
 * Run fn with a usable ptengine instance once loading completes.
 * The SDK may abort because of a domain mismatch / sampling / URL exclusion /
 * duplicate installation. In that case onload has fired but window.ptengine does
 * not exist, so warn explicitly, otherwise the event is dropped silently.
 * @param {string}   pageUrl
 * @param {function} fn
 * @param {string}   [label] event name, written into the warning to ease debugging
 */
function withPtengine(pageUrl, fn, label) {
  return loadPtengineScript(pageUrl)
    .then(() => {
      if (!window.ptengine) {
        console.warn(
          "ptengine not initialized (possible URL exclusion / sampling / duplicate installation), event dropped" +
            (label ? ": " + label : ""),
        );
        return;
      }
      fn(window.ptengine);
    })
    .catch((error) => {
      console.error(
        "Ptengine script loading failed" + (label ? ", event dropped: " + label : ""),
        error,
      );
    });
}

function trackEvent(eventType, eventProperties, event) {
  const options = eventProperties || {};
  return withPtengine(
    getPageUrl(event),
    (pt) => pt.track(eventType, options),
    eventType,
  );
}

// Remove empty properties to avoid sending null/undefined/'' fields
function removeEmptyKeys(obj) {
  for (let key in obj) {
    if (
      obj.hasOwnProperty(key) &&
      (obj[key] === null || obj[key] === undefined || obj[key] === "")
    ) {
      delete obj[key];
    }
  }
  return obj;
}

// Order completed: 1 order-level checkout_completed + 1 checkout_completed_order per line item
analytics.subscribe("checkout_completed", (event) => {
  const checkout = event?.data?.checkout;
  if (!checkout) return;

  // Extract the numeric ID from the order customer ID (gid://shopify/Customer/xxx) as uid
  const rawCustomerId = checkout?.order?.customer?.id;
  const numericCustomerId = rawCustomerId ? rawCustomerId.split("/").pop(): null;
  const uid = numericCustomerId;
  // ⚠️ Definition pending: totalPrice here is the final amount including tax and
  //    shipping, while checkout_started and payment_info_submitted use subtotalPrice
  //    (subtotal). The two amounts are not comparable within one funnel. Confirm with
  //    the customer which one to standardize on, or add the other field on both sides.
  const totalPrice = checkout?.totalPrice?.amount;
  const currencyCode = checkout?.totalPrice?.currencyCode;
  const orderId = checkout?.order?.id;
  const items = checkout?.lineItems || [];

  const eventProperties = removeEmptyKeys({
    totalPrice: totalPrice,
    currencyCode: currencyCode,
    totalorderid: orderId,
    productCount: items.length,
  });

  withPtengine(
    getPageUrl(event),
    (pt) => {
      // 1) identify: once per order, write order info to the user profile.
      //    Must be sent before track, otherwise later events are not attributed
      //    to the identified user.
      if (uid) {
        pt.identify(uid, {
          totalPrice: totalPrice,
          totalorderid: orderId,
        });
      }

      // 2) track: send the order-level checkout_completed only once (no line-item details)
      pt.track("checkout_completed", eventProperties);

      // 3) Line items: send 1 checkout_completed_order per item, carrying
      //    totalorderid to join with the order-level event
      items.forEach((item) => {
        const itemProperties = removeEmptyKeys({
          totalorderid: orderId,
          sku: item?.variant?.sku,
          name: item?.title,
          spu: item?.variant?.product?.type,
          // ⚠️ Definition pending: item.id is the line-item ID and differs on every
          //    order, so aggregating by it at the product level scatters completely.
          //    Product level should use item.variant.product.id, variant level
          //    item.variant.id. Changing the field's meaning affects existing
          //    reports, so confirm with the customer before changing.
          id: item?.id,
          quantity: item?.quantity,
          price: item?.finalLinePrice?.amount,
          currencyCode: currencyCode,
        });
        pt.track("checkout_completed_order", itemProperties);
      });
    },
    "checkout_completed",
  );
});

analytics.subscribe("search_submitted", (event) => {
  const eventProperties = removeEmptyKeys({
    keyword: event?.data?.searchResult?.query,
    resultCount: event?.data?.searchResult?.productVariants?.length,
  });
  trackEvent("search_submitted", eventProperties, event);
});

analytics.subscribe("collection_viewed", (event) => {
  const eventProperties = removeEmptyKeys({
    id: event?.data?.collection?.id,
    name: event?.data?.collection?.title,
    productCount: event?.data?.collection?.productVariants?.length,
  });
  trackEvent("collection_viewed", eventProperties, event);
});

analytics.subscribe("product_viewed", (event) => {
  const origin = event?.context?.window?.origin || window.location.origin;
  const variant = event?.data?.productVariant;
  const eventProperties = removeEmptyKeys({
    sku: variant?.sku,
    name: variant?.product?.title,
    // ⚠️ Definition pending: type and spu carry the same value, doubling the
    //    event-property quota usage with unclear semantics. Kept because an existing
    //    report may rely on either one; merge only after the customer confirms.
    type: variant?.product?.type,
    spu: variant?.product?.type,
    price: variant?.price?.amount,
    currencyCode: variant?.price?.currencyCode,
    url: variant?.product?.url ? origin + variant.product.url : undefined,
    imageUrl: variant?.image?.src,
  });
  trackEvent("product_viewed", eventProperties, event);
});

analytics.subscribe("product_added_to_cart", (event) => {
  const origin = event?.context?.window?.origin || window.location.origin;
  const merchandise = event?.data?.cartLine?.merchandise;
  const eventProperties = removeEmptyKeys({
    url: merchandise?.product?.url ? origin + merchandise.product.url: undefined,
    name: merchandise?.product?.title,
    sku: merchandise?.sku,
    quantity: event?.data?.cartLine?.quantity,
    price: merchandise?.price?.amount,
    currencyCode: merchandise?.price?.currencyCode,
    spu: merchandise?.product?.type,
  });
  trackEvent("product_added_to_cart", eventProperties, event);
});

analytics.subscribe("product_removed_from_cart", (event) => {
  const origin = event?.context?.window?.origin || window.location.origin;
  const merchandise = event?.data?.cartLine?.merchandise;
  const eventProperties = removeEmptyKeys({
    url: merchandise?.product?.url ? origin + merchandise.product.url : undefined,
    name: merchandise?.product?.title,
    sku: merchandise?.sku,
    quantity: event?.data?.cartLine?.quantity,
    price: merchandise?.price?.amount,
    currencyCode: merchandise?.price?.currencyCode,
    spu: merchandise?.product?.type,
  });
  trackEvent("product_removed_from_cart", eventProperties, event);
});

analytics.subscribe("cart_viewed", (event) => {
  const cart = event?.data?.cart;
  if (!cart) {
    trackEvent("cart_viewed", {}, event);
    return;
  }
  const origin = event?.context?.window?.origin || window.location.origin;
  const lines = cart?.lines || [];

  // ⚠️ Definition note: this sends one cart_viewed per line in the cart, so 3 items
  //    means 3 events with the same name. Event quota is consumed proportionally and
  //    the conversion-rate denominator inflates. Kept so as not to break existing
  //    reports. Switching to "1 cart-level + N item-level" is a definition change
  //    that needs the customer's confirmation and matching report updates first.
  if (lines.length) {
    lines.forEach((line) => {
      const merchandise = line?.merchandise;
      const eventProperties = removeEmptyKeys({
        totalQuantity: cart?.totalQuantity,
        url: merchandise?.product?.url ? origin + merchandise.product.url : undefined,
        name: merchandise?.product?.title,
        sku: merchandise?.sku,
        spu: merchandise?.product?.type,
        quantity: line?.quantity,
        // Deliberate choice: line.cost.totalAmount is the line subtotal, merchandise.price
        // is the unit price. They mean different things, so no fallback between them.
        // If cost is missing, drop the field rather than make price ambiguous.
        price: line?.cost?.totalAmount?.amount,
        currencyCode: line?.cost?.totalAmount?.currencyCode,
      });
      trackEvent("cart_viewed", eventProperties, event);
    });
  } else {
    trackEvent(
      "cart_viewed",
      removeEmptyKeys({ totalQuantity: cart?.totalQuantity }),
      event,
    );
  }
});

analytics.subscribe("checkout_started", (event) => {
  const checkout = event?.data?.checkout;
  const origin = event?.context?.window?.origin || window.location.origin;
  const checkoutItems = checkout?.lineItems;
  const totalPrice = checkout?.subtotalPrice?.amount;
  const currencyCode = checkout?.subtotalPrice?.currencyCode;

  // ⚠️ Same as cart_viewed: one checkout_started per line item, which inflates the
  //    same way. See above for the alternative.
  if (checkoutItems && checkoutItems.length) {
    checkoutItems.forEach((item) => {
      const eventProperties = removeEmptyKeys({
        totalPrice: totalPrice,
        currencyCode: currencyCode,
        url: item?.variant?.product?.url  ? origin + item.variant.product.url : undefined,
        name: item?.variant?.product?.title,
        sku: item?.variant?.sku,
        spu: item?.variant?.product?.type,
        quantity: item?.quantity,
        price: item?.finalLinePrice?.amount,
      });
      trackEvent("checkout_started", eventProperties, event);
    });
  } else {
    trackEvent(
      "checkout_started",
      removeEmptyKeys({ totalPrice, currencyCode }),
      event,
    );
  }
});

analytics.subscribe("checkout_contact_info_submitted", (event) => {
  trackEvent("checkout_contact_info_submitted", {}, event);
});

analytics.subscribe("checkout_address_info_submitted", (event) => {
  trackEvent("checkout_address_info_submitted", {}, event);
});

analytics.subscribe("checkout_shipping_info_submitted", (event) => {
  trackEvent("checkout_shipping_info_submitted", {}, event);
});

analytics.subscribe("payment_info_submitted", (event) => {
  const checkout = event?.data?.checkout;
  const eventProperties = removeEmptyKeys({
    totalPrice: checkout?.subtotalPrice?.amount,
    currencyCode: checkout?.subtotalPrice?.currencyCode,
  });
  trackEvent("payment_info_submitted", eventProperties, event);
});
```

#### 5. Publish

Finally, click the "Connect" button in the top right to publish the code set in Step 4.

### About Event Tag Firing Timing

## Important Notes

When using Ptengine with Shopify, please be sure to check the following points.

### **1. Please install the Ptengine basic tag directly in "Shopify themes"**

Please install the Ptengine basic tag directly in Shopify themes (theme.liquid).

Installing via Google Tag Manager may not allow event measurement due to Shopify's specifications.

Therefore, we do not recommend installing through tag managers such as GTM, and it is outside the scope of support.

### **2. Do not add page-by-page transmission restrictions or conditions**

We do not recommend modifying the Ptengine basic script to fire only on specific pages or restricting firing based on URL conditions.

Please understand that we cannot investigate the cause or provide support for any data measurement issues that occur in such cases.

### **3. If you change the theme, you need to reinstall the tags**

In Shopify, when you change a theme, the contents of theme.liquid are reset.

Therefore, if you switch themes or apply a new theme, please reinstall the Ptengine basic tag in the new theme as well.

Please note that if you do not reinstall the tag, Ptengine measurement will not be performed.
