How To Track Post-Purchase Upsells in Shopify Plus Checkout

Learn how to update your tracking to ensure maximum conversion tracking coverage for your upsells in the native Shopify Plus checkout

πŸ“˜

If using our Shopify Source / Data Layer version 3.9+ follow our updated guide:

How to Track Post-Purchase Upsells

If you are using an app like Zipify, Carthook, or others that trigger a post-purchase upsell funnel in the native Shopify checkout then please follow these instructions to update your store.

Background

Many of our customers have a combination of:

  • Server-side tracking for Facebook, Google Analytics, and potentially Google Ads
  • Client-side tracking for other channels like affiliates, Snapchat, native advertising, or display. This tracking typically lives either in a GTM web container and/or your Shopify (in checkout.liquid or thank you page script settings)

If this sounds like you then this means the implementation of your post-purchase tracking isn't just a simple copy/paste setup. Your actual implementation will differ based on things like:

  • Do you want one single "Purchase" event sent to Facebook even if a user takes 2 upsells OR do you want 3 unique "Purchase" events sent to Facebook.
  • Do you want upsell revenue tracked in GA?
  • Do you want upsell revenue tracked in your miscellaneous sources like TikTok, Snapchat, Impact, or other channels

This guide assumes the following as a baseline:

  1. You are using server-side tracking and specifically Elevar's webhook integration that removes the dependency on the browser triggering your main purchase conversions.
  2. You are using Elevar Data Layer & pre-built tags with your GTM Web Container
  3. You are on a current version of the Data Layer versions 3.6.x or higher. Not on this version? Learn how to update your data layer version
  4. You are not using checkout extensibility and have checkout.liquid available. If your checkout.liquid is not enabled or you are using checkout extensibility please follow this guide for non-plus or checkout extensibility upsell tracking.

πŸ“˜

If you are not using Elevar's server-side tracking and are relying on GTM web container for purchase events then this guide will still work, but you will likely miss out on some conversions being tracked.

How Upsell Funnels Work

You'll see the new post-purchase page settings in your Shopify checkout settings here:

If you have an upsell funnel enabled, after a purchase is made, an upsell offer is shown to the customer like this:

As soon as the upsell window is shown (like the image above), we send a dl_purchase event in the data layer for the initial order that can be used for client-side tracking in your GTM Web container. This event replaces the standard dl_purchase event that normally only fires on the order status page.

Then, if a customer takes the upsell, we fire a new event called dl_upsell_purchase. This happens before the final thank you page loads.

The purchase information sent on this event refers only to the upsell products (think of it as a completely separate purchase).

πŸ“˜

This is a little tricky at first - but the dl_upsell_purchase logic is handled in the post-purchase page script setting - NOT the final thank you page settings.

Step 1: Add Code to Post-Purchase Settings

The code below handles the scenarios where:

  • User views upsell offer page // we push a dl_purchase event
  • User takes an upsell or downsell offer // we push a dl_upsell_purchase event

Copy the script below into your Shopify post-purchase page checkout settings (shown in the previous image above). Be sure to update the GTM-xxxxx near the top of the script with your Web Container ID.

<script>
// If this page hasn't been seen push a dl_purchase event after the initial sale.

(function (w, d, s, l, i) {
    w[l] = w[l] || []; w[l].push({
        'gtm.start':
            new Date().getTime(), event: 'gtm.js'
    }); var f = d.getElementsByTagName(s)[0],
        j = d.createElement(s), dl = l != 'dataLayer' ? '&l=' + l : ''; j.async = true; j.src =
            'https://www.googletagmanager.com/gtm.js?id=' + i + dl; f.parentNode.insertBefore(j, f);
})(window, document, 'script', 'dataLayer', 'GTM-XXX');

var upsellCount = 0;
(function() {
// EVENT HOOKS -----------------------------------------------------------
if (!Shopify.wasPostPurchasePageSeen) {
    onCheckout(window.Shopify.order, window.Shopify);
}

Shopify.on('CheckoutAmended', function (newOrder, initialOrder) {
    onCheckoutAmended(newOrder, initialOrder, window.Shopify);
});
// END EVENT HOOKS -------------------------------------------------------

// UTILS -----------------------------------------------------------------
// Function called after original order is placed, pre upsell.
function onCheckout(initialOrder, shopifyObject) {
    window.dataLayer = window.dataLayer || [];
    pushDLPurchase(initialOrder, initialOrder.lineItems, false, null, shopifyObject);
}

// Function called when upsell is taken. Seperate the new/upsell
// items from the items in the initial order and then send a purchase event
// for just the new items.
function onCheckoutAmended(upsellOrder, initialOrder, shopifyObject) {
    // identify which items were added to the initial order, if any.
    upsellCount++;
    // The line item id is unique for order items, even if the items contained are the same.
    // We can use this to seperate out items from the initial order from the upsell.
    var initialItemIds = initialOrder.lineItems.map(function (line) { return line.id; });
    var addedItems = upsellOrder.lineItems.filter(
        function (line) { return initialItemIds.indexOf(line.id) < 0; }
    );
    // if no new items were added skip tracking
    if (addedItems.length === 0) return;
    pushDLPurchase(upsellOrder, addedItems, true, initialOrder, shopifyObject);
}

function pushDLPurchase(order, addedItems, isUpsell, initialOrder, shopifyObject) {
    window.dataLayer.push({
        'event': isUpsell ? 'dl_upsell_purchase' : 'dl_purchase',
        'event_id': getOrderId(order.id, isUpsell),
        'user_properties': getUserProperties(order),
        'ecommerce': {
            'purchase': {
                'actionField': getActionField(order, isUpsell, initialOrder, addedItems, shopifyObject),
                'products': getLineItems(addedItems),
            },
            'currencyCode': order.currency,
        },
    });
}
// Returns a user properties object
function getUserProperties(data) {
    return {
        'customer_id': data.customer.id,
        'customer_email': data.customer.email,
        'customer_first_name': data.customer.firstName,
        'customer_last_name': data.customer.lastName,
    }
}

// Gets line items in purchase
function getLineItems(lineItems) {
    return lineItems.map(function (item) {
        return {
            'category': item.product.type,
            'variant_id': item.variant.id.toString(),
            'product_id': Number(item.product.id).toString(),
            'id': item.variant.sku,
            // We don't get variant title details
            'variant': item.title,
            'name': item.title,
            'price': item.price.toString(),
            'quantity': item.quantity.toString(),
            // Not available
            // 'brand': orderItem.brand,
        }
    });
}

function getActionField(order, isUpsell, initialOrder, addedItems, shopifyObject) {
    var revenue = isUpsell ? getAdditionalRevenue(order, initialOrder) : order.totalPrice;
    var subtotal = isUpsell ? getAdditionalSubtotal(order, initialOrder) : order.subtotalPrice;
    try {
        affiliation = new URL(shopifyObject.pageUrl).hostname;
    } catch (e){
        affiliation = '';
    }
    return {
        'action': "purchase",
        'affiliation': affiliation,
        // This is the longer order id that shows in the url on an order page
        'id': getOrderId(order.id, isUpsell).toString(),
        // This should be the #1240 that shows in order page.
        'order_name': getOrderId(order.number, isUpsell).toString(),
        // This is total discount. Dollar value, not percentage
        // On the first order we can look at the discounts object. On upsells, we can't.
        // This needs to be a string.
        'discount_amount': getDiscountAmount(order, isUpsell, addedItems),
        // We can't determine shipping & tax. For the time being put the difference between subtotal and rev in shipping
        'shipping': (parseFloat(revenue) - parseFloat(subtotal)).toString(),
        'tax': '0',
        'revenue': revenue,
        'sub_total': subtotal,
    };
}

function getDiscountAmount(shopifyOrder, isUpsell, addedItems) {
    if (shopifyOrder.discounts === null || typeof shopifyOrder.discounts === 'undefined') return '0';
    if (shopifyOrder.discounts.length === 0) return '0';
    // If this isn't an upsell we can look at the discounts object.
    if (!isUpsell) {
        // Collect all the discounts on the first order.
        return shopifyOrder.discounts.reduce(function (acc, discount) {
            return acc += parseFloat(discount.amount);
        }, 0).toFixed(2).toString();
    // If this an upsell we have to look at the line item discounts
    // The discount block provided doesn't only applies to the first order.
    } else {
        return addedItems.reduce(function (acc, addedItem) {
            return acc += parseFloat(addedItem.lineLevelTotalDiscount);
        }, 0).toFixed(2).toString();
    }

}

function getOrderId(orderId, isUpsell) {
    return isUpsell ? orderId.toString() + '-US' + upsellCount.toString() : orderId;
}


function getAdditionalRevenue(newOrder, initialOrder) {
    return (parseFloat(newOrder.totalPrice) - parseFloat(initialOrder.totalPrice)).toFixed(2);
}

function getAdditionalSubtotal(newOrder, initialOrder) {
    return (parseFloat(newOrder.subtotalPrice) - parseFloat(initialOrder.subtotalPrice)).toFixed(2);
}

function test() {
    onCheckoutAmended(newOrder, initialOrder);
}

try {
    module.exports = exports = {
        onCheckoutAmended: onCheckoutAmended,
        onCheckout: onCheckout,
        resetUpsellCount: function(){upsellCount = 0;},
    };
} catch (e) { }

})();
</script>

Step 2: Update checkout.liquid template

🚧

If checkout.liquid is not available or your are using checkout extensibility

Follow this guide for non-plus or checkout extensibility upsell tracking.

In your checkout.liquid template, wrap your elevar-head.liquid and elevar-checkout-end.liquid snippets in the following "unless" logic:

{%- unless request.path contains 'thank_you' -%}

      ...

{%- endunless -%}

So the end results would look similar to:

{%- unless request.path contains 'thank_you' -%}

    {% render 'elevar-head' %}

{%- endunless -%}

.

.

.

{%- unless request.path contains 'thank_you' -%}

    {% render 'elevar-checkout-end' %}

{%- endunless -%}

This will load GTM and the Elevar dataLayer on all pages of the checkout except the thank you page.

Step 3: Update Code in Thank You Order Status Page Settings

There is a feature that Shopify makes available to try and prevent duplicate tracking.

It's a "unless post purchase page accessed", but it doesn't work in the checkout.liquid template.

So we need to add the data layer to your Order Status page settings.

Go to Settings -> Checkout and add the following code below to the "Order status page" box.

You will need to replace the items in bold:

  1. Update GTM-XXXX with your GTM container ID (3 instances)
  2. Update the elevar-gtm-suite-config script that is near the top of the codeblock. Replace this line in bold with the elevar-gtm-suite-config line from your elevar-head.liquid snippet (should be the last line of the elevar-head.liquid snippet)
<!--
Elevar Data Layer

This file is automatically updated and should not be edited directly.

https://knowledge.getelevar.com/how-to-customize-data-layer-version-2

-->
<!-- Google Tag Manager -->
<script>
  window.dataLayer = window.dataLayer || [];
</script>
<script>
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({"gtm.start":
  new Date().getTime(),event:"gtm.js"});var f=d.getElementsByTagName(s)[0],
  j=d.createElement(s),dl=l!="dataLayer"?"&l="+l:"";j.async=true;j.src=
  "https://www.googletagmanager.com/gtm.js?id="+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,"script","dataLayer","GTM-XXXX");
</script>
<!-- End Google Tag Manager -->
<script id="elevar-gtm-suite-config" type="application/json">{"gtm_id": "GTM-XXXX", "event_config": {"cart_reconcile": true, "cart_view": true, "checkout_complete": true, "checkout_step": true, "collection_view": true, "defers_collection_loading": false, "defers_search_results_loading": false, "product_add_to_cart": false, "product_add_to_cart_ajax": true, "product_remove_from_cart": true, "product_select": true, "product_view": true, "search_results_view": true, "user": true, "save_order_notes": true}, "gtm_suite_script": "https://shopify-gtm-suite.getelevar.com/shops/xxxxx/3.6.X/gtm-suite.js", "consent_enabled": false, "apex_domain": null}</script>

{%- if first_time_accessed and post_purchase_page_accessed != true -%}

<script>
  (() => {
    if (!window.__ElevarIsGtmSuiteCalled) {
      window.__ElevarIsGtmSuiteCalled = true;
      const init = () => {
  window.__ElevarDataLayerQueue = [];
  window.__ElevarListenerQueue = [];
  if (!window.dataLayer) window.dataLayer = [];
};
      init();
      window.__ElevarTransformItem = event => {
  if (typeof window.ElevarTransformFn === "function") {
    try {
      const result = window.ElevarTransformFn(event);
      if (typeof result === "object" && !Array.isArray(result) && result !== null) {
        return result;
      } else {
        console.error("Elevar Data Layer: `window.ElevarTransformFn` returned a value " + "that wasn't an object, so we've treated things as if this " + "function wasn't defined.");
        return event;
      }
    } catch (error) {
      console.error("Elevar Data Layer: `window.ElevarTransformFn` threw an error, so " + "we've treated things as if this function wasn't defined. The " + "exact error is shown below.");
      console.error(error);
      return event;
    }
  } else {
    return event;
  }
};
      window.ElevarPushToDataLayer = item => {
  const date = new Date();
  localStorage.setItem("___ELEVAR_GTM_SUITE--lastDlPushTimestamp", String(Math.floor(date.getTime() / 1000)));
  const enrichedItem = {
    event_id: window.crypto.randomUUID ? window.crypto.randomUUID() : String(Math.random()).replace("0.", ""),
    event_time: date.toISOString(),
    ...item
  };
  const transformedEnrichedItem = window.__ElevarTransformItem ? window.__ElevarTransformItem(enrichedItem) : enrichedItem;
  const payload = {
    raw: enrichedItem,
    transformed: transformedEnrichedItem
  };
  if (transformedEnrichedItem._elevar_internal?.isElevarContextPush) {
    window.__ElevarIsContextSet = true;
    window.__ElevarDataLayerQueue.unshift(payload);
    window.__ElevarListenerQueue.unshift(payload);
  } else {
    window.__ElevarDataLayerQueue.push(payload);
    window.__ElevarListenerQueue.push(payload);
  }
  window.dispatchEvent(new CustomEvent("elevar-listener-notify"));
  if (window.__ElevarIsContextSet) {
    while (window.__ElevarDataLayerQueue.length > 0) {
      const event = window.__ElevarDataLayerQueue.shift().transformed;
      window.dataLayer.push(event);
    }
  }
};

      const configElement = document.getElementById("elevar-gtm-suite-config");

      if (!configElement) {
        console.error("Elevar: DL Config element not found");
        return;
      }

      const config = JSON.parse(configElement.textContent);

      const script = document.createElement("script");
      script.type = "text/javascript";
      script.src = config.gtm_suite_script;
      script.async = false;
      script.defer = true;

      script.onerror = () => {
        console.error("Elevar: DL JS script failed to load");
      };
      script.onload = async () => {
        if (!window.ElevarGtmSuite) {
          console.error("Elevar: `ElevarGtmSuite` is not defined");
          return;
        }

        window.ElevarGtmSuite.utils.emailCapture();

        const cartData = {
  attributes: {{- cart.attributes | json -}},
  cartTotal: "{{- cart.total_price | times: 0.01 | json -}}",
  currencyCode: {{- cart.currency.iso_code | json -}},
  items: [
    {%- for line_item in cart.items -%}
      {
        {%- if line_item.sku != blank -%}
          id: {{- line_item.sku | json -}},
        {%- else -%}
          id: "{{- line_item.product_id | json -}}",
        {%- endif -%}
        name: {{- line_item.product.title | json -}},
        brand: {{- line_item.vendor | json -}},
        category: {{- line_item.product.type | json -}},
        variant: {{- line_item.variant.title | json -}},
        price: "{{- line_item.final_price | times: 0.01 | json -}}",
        position: {{- forloop.index -}},
        quantity: "{{- line_item.quantity | json -}}",
        productId: "{{- line_item.product_id | json -}}",
        variantId: "{{- line_item.variant_id -}}",
        compareAtPrice: "{{- line_item.variant.compare_at_price | times: 0.01 | json -}}",
        image: "{{- line_item.image | image_url -}}"
      },
    {%- endfor -%}
  ]
}
;

        await window.ElevarGtmSuite.handlers.cartAttributesReconcile(
          cartData,
          config.event_config.save_order_notes,
          config.consent_enabled,
          config.apex_domain
        );

        if (config.event_config.user) {
          const data = {
  {%- if checkout -%}
    cartTotal: "{{- checkout.total_price | times: 0.01 | json -}}",
    currencyCode: {{- checkout.currency | json -}},
  {%- else -%}
    cartTotal: "{{- cart.total_price | times: 0.01 | json -}}",
    currencyCode: {{- cart.currency.iso_code | json -}},
  {%- endif -%}
  {%- if customer -%}
    customer: {
      id: "{{- customer.id | json -}}",
      email: {{- customer.email | json -}},
      firstName: {{- customer.first_name | json -}},
      lastName: {{- customer.last_name | json -}},
      phone: {{- customer.phone | json -}},
      city: {{- customer.default_address.city | json -}},
      zip: {{- customer.default_address.zip | json -}},
      address1: {{- customer.default_address.address1 | json -}},
      address2: {{- customer.default_address.address2 | json -}},
      country: {{- customer.default_address.country | json -}},
      countryCode: {{- customer.default_address.country_code | json -}},
      province: {{- customer.default_address.province | json -}},
      provinceCode: {{- customer.default_address.province_code | json -}},
      tags: {{- customer.tags | join: ', ' | json -}}
    }
  {%- endif -%}
};
          window.ElevarGtmSuite.handlers.user(data);
        }

        {%- if first_time_accessed -%}
        if (config.event_config.checkout_complete) {
          const data = {%- if checkout -%}
  {
    currencyCode: {{- checkout.currency | json -}},
    actionField: {
      {%- if checkout.order_id -%}
        id: {{- checkout.order_id | json -}},
      {%- else -%}
        id: {{- checkout.id | json -}},
      {%- endif -%}
      {%- if checkout.order_name -%}
        order_name: {{- checkout.order_name | json -}},
      {%- endif -%}
      affiliation: {{- shop.name | json -}},
      revenue: "{{- checkout.total_price | times: 0.01 | json -}}",
      tax: "{{- checkout.tax_price | times: 0.01 | json -}}",
      shipping: "{{- checkout.shipping_price | times: 0.01 | json -}}",
      {%- if checkout.discount_applications -%}
        coupon: {{- checkout.discount_applications[0].title | json -}},
      {%- endif -%}
      {%- if order.subtotal_price -%}
        subTotal: "{{- order.subtotal_price | times: 0.01 | json -}}",
      {%- elsif checkout.subtotal_price -%}
        subTotal: "{{- checkout.subtotal_price | times: 0.01 | json -}}",
      {%- endif -%}
      productSubTotal: "{{- checkout.line_items_subtotal_price | times: 0.01 | json -}}",
      discountAmount: "{{- checkout.discounts_amount | times: 0.01 | json -}}"
    },
    {%- if checkout.customer -%}
      customer: {
        id: "{{- checkout.customer.id | json -}}",
        email: {{- checkout.email | json -}},
        firstName: {{- checkout.billing_address.first_name | json -}},
        lastName: {{- checkout.billing_address.last_name | json -}},
        {%- if checkout.customer.phone -%}
          phone: {{- checkout.customer.phone | json -}},
        {%- elsif checkout.billing_address.phone -%}
          phone: {{- checkout.billing_address.phone | json -}},
        {%- else -%}
          phone: {{- checkout.shipping_address.phone | json -}},
        {%- endif -%}
        city: {{- checkout.billing_address.city | json -}},
        zip: {{- checkout.billing_address.zip | json -}},
        address1: {{- checkout.billing_address.address1 | json -}},
        address2: {{- checkout.billing_address.address2 | json -}},
        country: {{- checkout.billing_address.country | json -}},
        countryCode: {{- checkout.billing_address.country_code | json -}},
        province: {{- checkout.billing_address.province | json -}},
        provinceCode: {{- checkout.billing_address.province_code | json -}},
        tags: {{- checkout.customer.tags | json -}}
      },
    {%- endif -%}
    items: [
      {%- for line_item in checkout.line_items -%}
        {
          {%- if line_item.sku != blank -%}
            id: {{- line_item.sku | json -}},
          {%- else -%}
            id: "{{- line_item.product_id | json -}}",
          {%- endif -%}
          name: {{- line_item.product.title | json -}},
          brand: {{- line_item.vendor | json -}},
          category: {{- line_item.product.type | json -}},
          variant: {{- line_item.variant.title | json -}},
          price: "{{- line_item.final_price | times: 0.01 | json -}}",
          quantity: "{{- line_item.quantity | json -}}",
          productId: "{{- line_item.product_id | json -}}",
          variantId: "{{- line_item.variant_id -}}",
          image: "{{- line_item.image | image_url -}}"
        },
      {%- endfor -%}
    ],
    landingSite: {{- checkout.landing_site | json -}}
  }
{%- endif -%}
;
          window.ElevarGtmSuite.handlers.checkoutComplete(data);
        }
        {%- endif -%}
      };

      document.head.appendChild(script);
    }
  })();
</script>
{% endif %}

<!-- Google Tag Manager (noscript) -->
<noscript>
    <iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXX" height="0" width="0" style="display:none;visibility:hidden"></iframe>
</noscript>
<!-- End Google Tag Manager (noscript) -->

This will load GTM for you and give you a dl_purchase event if the post purchase page was not seen (the purchase has not been sent yet).

Step 4: Import Pre-Built Container(s)

First let's configure upsell revenue and events for GA and Facebook:

Download our Upsell Purchases pre-built container from your Elevar dashboard that ultimately looks like this inside of GTM:

In the default setup we attach a Facebook Upsell Purchase tag to the dl_upsell_purchase event. This sends a custom conversion event so we don't inflate FB Purchase conversions.

We also attach a custom GA event to the upsell that hyphenates the original order id (for ex. order 1234-US1). This lets you create a view in GA that filters out upsells and prevents inflated conversion rates.

Sending primary (and upsell) purchase events to non GA & Facebook channels

If you are already using Elevar's pre-built container for other channels then you only need to account for the dl_upsell_purchase events.

πŸ“˜

The reason that you only need to account for dl_upsell_purchase events is because our code from the previous step will already push a dl_purchase event on the upsell offer page. The dl_purchase event is the trigger that all of our pre-built containers use.

For example let's say you have Google Ads configured in GTM for primary purchase events, but you want to send a Google Ads conversion for upsell purchases as well.

You can create a new conversion in Google Ads and assign to the same upsell_purchase trigger used by Facebook:

For any channel you want to track, just be sure that you don't have hard coded conversions inside your Shopify thank you page AND triggering from GTM. Otherwise you'll have duplicates.

Step 4: Publish Updates & QA

One big limitation with the new post-purchase page settings is that you can't use GTM preview mode since it's sandboxed javascript. This means you'll need to publish your container, place a test order, and verify using dev tools in your browser OR inside your events manager.

Known Limitations

We can't determine shipping or tax alone. We get revenue, and a subtotal. From there we have to guess what portion of the difference is tax and what portion is shipping. By default we attribute the difference in revenue and subtotal to shipping.
We don't have all the normal purchase data on the page that Shopify typically provides. For example no customer address information is provided.
Liquid syntax is not supported inside the post-purchase page settings

Troubleshooting:

  1. If you aren’t getting purchase events try removing the exclamation mark (not) from line 5. Changing this (!Shopify.wasPostPurchasePageSeen) to this (Shopify.wasPostPurchasePageSeen)

  2. Update your data layer to most current version. [ Guide ]

  3. Make sure you have the GTM container filled in.