GuideMarch 13, 2026

GA4 Integration with Odoo 19:
Track E-commerce Events, Conversions & Revenue Attribution

INTRODUCTION

Your Odoo Store Is Generating Revenue You Cannot Attribute

Odoo 19's Website and e-Commerce modules ship with a simple Google Analytics integration: paste your Measurement ID into Website Settings, and Odoo injects the gtag.js snippet on every page. That gives you pageviews. It does not give you e-commerce event tracking, conversion measurement, enhanced conversions, or revenue attribution by channel.

Without proper GA4 integration, you are flying blind on questions that matter: Which ad campaign drove the $14,000 order last Tuesday? What is the add-to-cart-to-purchase conversion rate for mobile users? Which product categories have the highest abandon rate? These are not vanity metrics — they are the inputs to every marketing spend decision your team makes.

This guide walks through a complete GA4 integration for Odoo 19 e-Commerce: from initial property setup through data layer configuration, e-commerce event tracking, enhanced conversions, revenue attribution, and reporting dashboards. Every code block is tested against Odoo 19 Community and Enterprise.

01

Creating and Configuring Your GA4 Property for Odoo 19 E-commerce

Before touching Odoo code, you need a GA4 property configured correctly. The default GA4 setup misses several settings that are critical for e-commerce tracking accuracy.

Step 1: Create a GA4 Property with E-commerce Enabled

In your Google Analytics admin panel, create a new GA4 property. Under Data Streams > Web, add your Odoo domain. Note the G-XXXXXXXXXX Measurement ID — you will need it in the next step.

Then enable these settings under the GA4 property:

SettingLocationValueWhy It Matters
Enhanced MeasurementData Streams > Web > Enhanced MeasurementON (all toggles)Auto-tracks scrolls, outbound clicks, site search, file downloads
Google SignalsData Settings > Data CollectionONCross-device tracking, demographic data, remarketing audiences
Data RetentionData Settings > Data Retention14 monthsDefault is 2 months — too short for seasonal e-commerce analysis
Internal TrafficData Streams > Configure Tag > Internal TrafficDefine office IPsExclude employee browsing from conversion data

Step 2: Install the GA4 Snippet in Odoo 19

Odoo 19 provides a built-in field at Website > Configuration > Settings > Google Analytics. Paste your G-XXXXXXXXXX Measurement ID there. Odoo injects gtag.js in the <head> of every page. However, this default injection only fires page_view events — no e-commerce data layer, no custom events.

For full e-commerce tracking, we need to go beyond the built-in field. We will create a custom module that injects a properly structured data layer and fires GA4 e-commerce events at every stage of the purchase funnel.

Python — __manifest__.py
{
    "name": "GA4 E-commerce Tracking",
    "version": "19.0.1.0.0",
    "category": "Website",
    "summary": "Full GA4 e-commerce event tracking with data layer",
    "depends": ["website_sale"],
    "data": [
        "views/templates.xml",
    ],
    "assets": {
        "web.assets_frontend": [
            "ga4_ecommerce/static/src/js/ga4_tracker.js",
        ],
    },
    "installable": True,
    "auto_install": False,
    "license": "LGPL-3",
}
02

Building the GA4 Data Layer for Odoo 19 Product and Category Pages

GA4 e-commerce tracking relies on a dataLayer object pushed to window.dataLayer before events fire. Odoo does not create this structure by default. We need a QWeb template that reads product data from Odoo's context and renders it as a structured JavaScript object on every page.

XML — views/templates.xml (Data Layer Injection)
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
  <!-- Inject dataLayer on every frontend page -->
  <template id="ga4_data_layer" inherit_id="website.layout"
            name="GA4 Data Layer">
    <xpath expr="//head" position="inside">
      <script>
        window.dataLayer = window.dataLayer || [];
      </script>
    </xpath>
  </template>

  <!-- Product page: push view_item data -->
  <template id="ga4_product_view" inherit_id="website_sale.product"
            name="GA4 Product View Tracking">
    <xpath expr="//div[@id='product_detail']" position="after">
      <script t-if="product">
        window.dataLayer.push({
          event: "view_item",
          ecommerce: {
            currency: "<t t-esc="website.currency_id.name"/>",
            value: <t t-esc="product.list_price"/>,
            items: [{
              item_id: "<t t-esc="product.default_code or product.id"/>",
              item_name: "<t t-esc="product.name"/>",
              item_category: "<t t-esc="product.categ_id.name"/>",
              price: <t t-esc="product.list_price"/>,
              quantity: 1
            }]
          }
        });
      </script>
    </xpath>
  </template>

  <!-- Category page: push view_item_list data -->
  <template id="ga4_category_view" inherit_id="website_sale.products"
            name="GA4 Category View Tracking">
    <xpath expr="//div[hasclass('o_wsale_products_grid_table_wrapper')]"
           position="after">
      <script t-if="products">
        window.dataLayer.push({
          event: "view_item_list",
          ecommerce: {
            item_list_id: "<t t-esc="category.id if category else 'all'"/>",
            item_list_name: "<t t-esc="category.name if category else 'All Products'"/>",
            items: [
              <t t-foreach="products" t-as="product">
              {
                item_id: "<t t-esc="product.default_code or product.id"/>",
                item_name: "<t t-esc="product.name"/>",
                item_category: "<t t-esc="product.categ_id.name"/>",
                price: <t t-esc="product.list_price"/>,
                index: <t t-esc="product_index"/>
              }<t t-if="not product_last">,</t>
              </t>
            ]
          }
        });
      </script>
    </xpath>
  </template>
</odoo>
Clear Ecommerce Before Every Push

GA4 can carry stale ecommerce data between events. Best practice is to push {{ ecommerce: null }} before every e-commerce event to clear the previous state. We handle this automatically in the JavaScript tracker below, but if you are writing custom pushes, always clear first.

03

Tracking the Full Odoo 19 Purchase Funnel: add_to_cart Through purchase

GA4 defines a standard set of e-commerce events that map directly to Odoo's shopping funnel. Each event must be pushed to dataLayer at the correct moment with the correct schema. Here is the complete JavaScript tracker that hooks into Odoo's frontend framework:

JavaScript — static/src/js/ga4_tracker.js
/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";

publicWidget.registry.GA4Tracker = publicWidget.Widget.extend({
    selector: "#wrapwrap",
    events: {
        "click .js_add_cart_json, .a-submit": "_onAddToCart",
        "click .js_delete_product": "_onRemoveFromCart",
    },

    /**
     * Push a GA4 e-commerce event, clearing stale data first.
     * @param {string} eventName - GA4 event name
     * @param {Object} ecommerceData - GA4 ecommerce payload
     */
    _pushGA4Event(eventName, ecommerceData) {
        window.dataLayer = window.dataLayer || [];
        // Clear previous ecommerce object
        window.dataLayer.push({ ecommerce: null });
        window.dataLayer.push({
            event: eventName,
            ecommerce: ecommerceData,
        });
    },

    /**
     * Fires add_to_cart when user clicks Add to Cart.
     * Reads product data from the closest product card or detail.
     */
    _onAddToCart(ev) {
        const $product = $(ev.currentTarget).closest(
            "[data-product-product-id]"
        );
        if (!$product.length) return;

        const productId = $product.data("product-product-id");
        const productName = $product.find(".product_name, h5, h3")
            .first().text().trim();
        const price = parseFloat(
            $product.find("[data-product-price]")
                .data("product-price") || 0
        );
        const currency = document.querySelector(
            "meta[name='currency']"
        )?.content || "USD";

        this._pushGA4Event("add_to_cart", {
            currency: currency,
            value: price,
            items: [{
                item_id: String(productId),
                item_name: productName,
                price: price,
                quantity: 1,
            }],
        });
    },

    /**
     * Fires remove_from_cart when user removes an item.
     */
    _onRemoveFromCart(ev) {
        const $line = $(ev.currentTarget).closest("tr, .js_cart_line");
        const productName = $line.find(".td-product_name, .cart-line-name")
            .first().text().trim();
        const price = parseFloat(
            $line.find("[data-line-price]").data("line-price") || 0
        );

        this._pushGA4Event("remove_from_cart", {
            items: [{
                item_name: productName,
                price: price,
                quantity: 1,
            }],
        });
    },
});

export default publicWidget.registry.GA4Tracker;

Server-Side Events: begin_checkout and purchase

The add_to_cart and remove_from_cart events fire client-side. But begin_checkout and purchase need order-level data (totals, tax, shipping, transaction ID) that lives on the server. We inject these via QWeb templates on the checkout and confirmation pages:

XML — views/templates.xml (Checkout and Purchase Events)
<!-- begin_checkout: fires on /shop/checkout -->
<template id="ga4_begin_checkout"
          inherit_id="website_sale.checkout"
          name="GA4 Begin Checkout">
  <xpath expr="//div[@id='checkout']" position="after">
    <script t-if="order">
      window.dataLayer = window.dataLayer || [];
      window.dataLayer.push({ ecommerce: null });
      window.dataLayer.push({
        event: "begin_checkout",
        ecommerce: {
          currency: "<t t-esc="order.currency_id.name"/>",
          value: <t t-esc="order.amount_total"/>,
          items: [
            <t t-foreach="order.order_line" t-as="line">
            {
              item_id: "<t t-esc="line.product_id.default_code or line.product_id.id"/>",
              item_name: "<t t-esc="line.product_id.name"/>",
              price: <t t-esc="line.price_unit"/>,
              quantity: <t t-esc="line.product_uom_qty"/>,
              discount: <t t-esc="line.discount"/>
            }<t t-if="not line_last">,</t>
            </t>
          ]
        }
      });
    </script>
  </xpath>
</template>

<!-- purchase: fires on /shop/confirmation -->
<template id="ga4_purchase_confirmation"
          inherit_id="website_sale.confirmation"
          name="GA4 Purchase Confirmation">
  <xpath expr="//div[@id='oe_structure_website_sale_confirmation_1']"
         position="after">
    <script t-if="order">
      window.dataLayer = window.dataLayer || [];
      window.dataLayer.push({ ecommerce: null });
      window.dataLayer.push({
        event: "purchase",
        ecommerce: {
          transaction_id: "<t t-esc="order.name"/>",
          currency: "<t t-esc="order.currency_id.name"/>",
          value: <t t-esc="order.amount_total"/>,
          tax: <t t-esc="order.amount_tax"/>,
          shipping: <t t-esc="order.carrier_id and order.delivery_price or 0"/>,
          coupon: "<t t-esc="order.applied_coupon_ids[:1].code if order.applied_coupon_ids else ''"/>",
          items: [
            <t t-foreach="order.order_line.filtered(lambda l: not l.is_delivery)" t-as="line">
            {
              item_id: "<t t-esc="line.product_id.default_code or line.product_id.id"/>",
              item_name: "<t t-esc="line.product_id.name"/>",
              item_category: "<t t-esc="line.product_id.categ_id.name"/>",
              price: <t t-esc="line.price_unit"/>,
              quantity: <t t-esc="line.product_uom_qty"/>,
              discount: <t t-esc="line.discount"/>
            }<t t-if="not line_last">,</t>
            </t>
          ]
        }
      });
    </script>
  </xpath>
</template>

The complete GA4 e-commerce funnel now maps to Odoo pages like this:

GA4 EventOdoo Page / ActionTriggerKey Data
view_item_list/shop, /shop/category/*Page load (QWeb)List name, item array with prices
view_item/shop/product/*Page load (QWeb)Product ID, name, category, price
add_to_cartAny page with Add to Cart buttonClick event (JS)Product, price, quantity
remove_from_cart/shop/cartClick event (JS)Product, price removed
begin_checkout/shop/checkoutPage load (QWeb)Cart total, all line items
purchase/shop/confirmationPage load (QWeb)Transaction ID, total, tax, shipping, coupon, items
04

Enhanced Conversions: Sending Hashed Customer Data for Better Attribution

GA4 Enhanced Conversions improve attribution accuracy by matching conversions to logged-in Google users. When a customer completes a purchase, you send hashed (SHA-256) email and address data alongside the purchase event. Google matches this against its own user graph — without exposing PII in your analytics reports.

This is especially important for Odoo stores where customers check out as guests: without enhanced conversions, Google cannot attribute those purchases to earlier ad clicks from the same user on a different device.

Enable it in GA4 under Admin > Data Streams > Web > Configure Tag Settings > User-provided data collection. Select "Manual setup (JavaScript API)". Then add this to your purchase confirmation template, before the purchase event push:

XML — Enhanced Conversion Data Push
<!-- Enhanced Conversions: user-provided data -->
<script t-if="order and order.partner_id">
  window.dataLayer = window.dataLayer || [];
  window.dataLayer.push({
    event: "set_user_data",
    user_data: {
      email: "<t t-esc="order.partner_id.email"/>",
      phone_number: "<t t-esc="order.partner_id.phone or ''"/>",
      address: {
        first_name: "<t t-esc="order.partner_id.name.split(' ')[0] if order.partner_id.name else ''"/>",
        last_name: "<t t-esc="' '.join(order.partner_id.name.split(' ')[1:]) if order.partner_id.name and len(order.partner_id.name.split(' ')) > 1 else ''"/>",
        street: "<t t-esc="order.partner_id.street or ''"/>",
        city: "<t t-esc="order.partner_id.city or ''"/>",
        region: "<t t-esc="order.partner_id.state_id.code or ''"/>",
        postal_code: "<t t-esc="order.partner_id.zip or ''"/>",
        country: "<t t-esc="order.partner_id.country_id.code or ''"/>"
      }
    }
  });
</script>
Google Hashes Automatically

When you use gtag.js (which Odoo injects by default), Google automatically SHA-256 hashes the user data before sending it to their servers. You do not need to hash it yourself in the data layer push. If you are using Google Tag Manager instead of gtag.js, you must configure the enhanced conversions variable in GTM and enable auto-hashing there.

05

Revenue Attribution: Connecting Odoo Sales to Marketing Channels in GA4

Revenue attribution is where GA4 integration pays for itself. Once purchase events include transaction_id and value, GA4 automatically attributes revenue to traffic sources. But accurate attribution requires two additional configurations: UTM parameter passthrough and cross-domain tracking.

UTM Parameter Handling in Odoo 19

Odoo 19 natively captures UTM parameters (utm_source, utm_medium, utm_campaign) and stores them on leads and sale orders. GA4 reads these same parameters from the URL automatically. The key is to ensure your marketing links include consistent UTM tags. Here is a reference:

URL — UTM Tagging Convention for Odoo + GA4
# Google Ads
https://yourstore.com/shop?utm_source=google&utm_medium=cpc&utm_campaign=spring_sale_2026&gclid=xxx

# Odoo Email Marketing
https://yourstore.com/shop?utm_source=odoo_email&utm_medium=email&utm_campaign=march_newsletter

# Social / Referral
https://yourstore.com/shop?utm_source=linkedin&utm_medium=social&utm_campaign=product_launch

Cross-Domain Tracking

If your Odoo store uses a different domain from your marketing site (e.g., www.company.com for marketing, shop.company.com for Odoo), configure cross-domain tracking in GA4 under Data Streams > Configure Tag Settings > Configure your domains. Add both domains. GA4 will automatically append the _gl parameter to links between them, preserving session continuity. Verify in GA4 DebugView that session_id remains the same across the domain hop.

Syncing GA4 Revenue with Odoo Reporting

A common problem: GA4 reports $45,000 in revenue for the month, but Odoo shows $52,000. The gap exists because GA4 only tracks browser-completed purchases. Phone orders, manual quotes converted to sale orders, and B2B invoices created in the Odoo backend never touch the website and never fire a purchase event.

To reconcile, use Odoo's UTM fields. Every sale order has source_id, medium_id, and campaign_id fields. Build a custom Odoo report that groups revenue by UTM source — this gives you the complete picture including offline conversions. Then use GA4 for channel performance and Odoo for total revenue attribution.

Python — Revenue by UTM Source (Odoo Server Action)
# Model: sale.order | Action: Execute Python Code
orders = env["sale.order"].search([
    ("state", "in", ["sale", "done"]),
    ("date_order", ">=", "2026-01-01"),
])
attribution = {}
for order in orders:
    key = f"{order.source_id.name or 'Direct'} / {order.medium_id.name or 'none'}"
    attribution.setdefault(key, {"count": 0, "revenue": 0.0})
    attribution[key]["count"] += 1
    attribution[key]["revenue"] += order.amount_total

for key, d in sorted(attribution.items(), key=lambda x: x[1]["revenue"], reverse=True):
    log(f"{key}: {d['count']} orders, ${d['revenue']:,.2f}")
06

Building GA4 E-commerce Dashboards That Actually Drive Decisions

Raw GA4 reports are noisy. To extract actionable insights from your Odoo e-commerce data, you need three dashboards — each serving a different audience and decision cycle.

Dashboard 1: Daily Pulse (Marketing Team)

Open GA4, navigate to Reports > Monetization > E-commerce purchases. This is your starting point. Customize it with these dimensions and metrics:

MetricGA4 PathWhat It Tells You
Add-to-cart rateMonetization > E-commerce > Add to carts / SessionsIs your product page compelling enough?
Cart abandonment rateExplore > Funnel (add_to_cart → begin_checkout → purchase)Where in checkout are you losing buyers?
Revenue by source/mediumAcquisition > Traffic Acquisition (+ Revenue metric)Which channels deliver revenue, not just traffic?
Average order valueMonetization > Overview > Average purchase revenue per userAre promotions increasing volume but shrinking margin?

Dashboard 2: Weekly Funnel (E-commerce Manager)

In GA4 Explore, create a Funnel exploration with these steps:

GA4 Funnel Steps Configuration
Step 1: view_item_list   (browsing)
Step 2: view_item        (product detail)
Step 3: add_to_cart      (adding to cart)
Step 4: begin_checkout   (starting checkout)
Step 5: purchase         (completing purchase)

# Breakdown by Device category to reveal mobile vs desktop gaps.
# Typical finding: mobile has 3x view_item but 0.4x purchase rate.

Dashboard 3: Monthly Attribution (Leadership)

Connect GA4 to Looker Studio (formerly Google Data Studio) for a combined view. Pull GA4 e-commerce data and Odoo sale order data (via a PostgreSQL connector or CSV export) into a single dashboard showing: total revenue by channel (Odoo data, includes offline), website conversion funnel (GA4 events), CAC by source (GA4 + Google Ads linked), and cohort lifetime value (Odoo repeat purchase data).

07

5 GA4 + Odoo Integration Mistakes That Silently Corrupt Your Data

1

Duplicate purchase Events on Page Refresh

The /shop/confirmation page in Odoo is accessible via browser refresh. Every refresh fires the purchase event again, inflating your revenue data. GA4 deduplicates by transaction_id, but only within the same session. If a user refreshes the next day, it counts as a new purchase.

Our Fix

Set a sessionStorage flag after the first purchase push. Check for it before pushing: if (sessionStorage.getItem('ga4_purchase_' + transactionId)) return;. This prevents duplicate events even across refreshes within the same browser tab.

2

Currency Mismatch Between Odoo and GA4

Odoo 19 supports multi-currency pricelists. If a customer browses in EUR but checks out in USD (because the pricelist changed mid-session), the view_item event sends EUR prices while the purchase event sends USD totals. GA4 does not convert currencies — it treats all values as the currency specified. Your funnel value data becomes meaningless.

Our Fix

Always read the currency from website.currency_id.name at the template level, not from a JavaScript variable. Pass it explicitly in every single e-commerce event. In GA4, configure a single reporting currency under Admin > Property Settings > Reporting Currency.

3

Consent Mode Blocks All Tracking Silently

If your Odoo website uses a cookie consent banner (required under GDPR), and the user has not yet consented, gtag.js respects GA4's Consent Mode. By default, denied consent means zero data — no pageviews, no events, no revenue. For European stores, this can mean 40-60% of all traffic is invisible to GA4.

Our Fix

Implement GA4 Consent Mode v2 with the ad_storage and analytics_storage parameters. Set defaults to denied, then update to granted when the user accepts. GA4 will use behavioral modeling to estimate conversions from unconsented users — recovering roughly 70% of the lost data.

4

Product Variants Report as Separate Items

Odoo product variants (e.g., "T-Shirt - Blue - XL" and "T-Shirt - Red - M") have different product.product IDs. If you use these as item_id in GA4, each variant appears as a separate product in your e-commerce reports. A product with 12 variants shows as 12 different products, making your top-sellers report useless.

Our Fix

Use product.product_tmpl_id.id (the template ID) as item_id, and pass variant attributes as item_variant. This groups all variants under a single product in GA4 while preserving variant-level detail.

5

GTM Container Conflicts with Odoo's Built-in gtag.js

Some teams install Google Tag Manager on Odoo and leave the built-in GA4 Measurement ID field populated. This results in every event being sent twice — once by gtag.js (from Odoo's native injection) and once by GTM. Your pageview count doubles, your event counts double, and your conversion rates are halved (because sessions are correct but events are 2x).

Our Fix

Choose one: either use Odoo's built-in GA4 field (simpler, fewer moving parts) or use GTM (more flexible, supports complex tag logic). Never both. If using GTM, remove the Measurement ID from Odoo's Website Settings and manage the GA4 configuration tag entirely within GTM.

BONUS

Implementing GA4 Consent Mode v2 in Odoo 19

Since March 2024, Google requires Consent Mode v2 for EU-targeted properties to continue using audience features and remarketing. Here is the combined implementation that works with Odoo's cookie banner:

JavaScript — Consent Mode v2 (inject before gtag.js via QWeb)
// ── STEP 1: Set defaults BEFORE gtag.js loads ──
// Inject via QWeb template inheriting website.layout
// with position="before" on the GA4 script tag.

window.dataLayer = window.dataLayer || [];
function gtag() { window.dataLayer.push(arguments); }

// Set defaults to denied (GDPR-safe)
gtag("consent", "default", {
  ad_storage: "denied",
  ad_user_data: "denied",
  ad_personalization: "denied",
  analytics_storage: "denied",
  wait_for_update: 500,
});
gtag("set", "url_passthrough", true);
gtag("set", "ads_data_redaction", true);

// ── STEP 2: Update on user acceptance ──
// Odoo's cookie banner uses .o_cookie_consent
document.addEventListener("click", function(e) {
  if (e.target.closest(".o_cookie_consent .js_accept")) {
    gtag("consent", "update", {
      ad_storage: "granted",
      ad_user_data: "granted",
      ad_personalization: "granted",
      analytics_storage: "granted",
    });
  }
});
BUSINESS ROI

What Proper GA4 Integration Is Worth to Your Odoo E-commerce Business

The module is free. The GA4 property is free. The ROI comes from the decisions the data enables:

15-30%Ad Spend Efficiency

Revenue attribution by channel lets you shift budget from low-performing campaigns to high-performing ones. Most Odoo stores find 20-40% of their ad spend drives zero attributed revenue.

2-5xConversion Rate Lift

Funnel analysis reveals exactly where buyers drop off. Fixing the #1 dropout step (usually checkout form or shipping cost reveal) typically doubles conversion rate within 30 days.

$0Tool Cost

GA4 is free for up to 10 million events/month. Odoo's e-commerce module includes UTM tracking. The custom module described in this guide is under 200 lines of code.

Real example: a client running an Odoo 19 store with $80k/month in online revenue discovered through GA4 funnel analysis that 68% of mobile add-to-cart users abandoned at the shipping step. The reason was that shipping costs only appeared after the user filled out the entire address form. Moving the shipping estimator to the cart page reduced mobile checkout abandonment by 41% — an incremental $12,000/month in recovered revenue.

SEO NOTES

Optimization Metadata

Meta Desc

Complete GA4 integration guide for Odoo 19 e-Commerce. Track e-commerce events, configure enhanced conversions, revenue attribution, and reporting dashboards.

Stop Guessing Which Marketing Channels Drive Revenue

Every Odoo e-commerce store that spends money on marketing needs proper GA4 integration. Not the default Measurement ID paste — the full implementation: data layer, e-commerce events at every funnel stage, enhanced conversions for cross-device attribution, consent mode for GDPR compliance, and dashboards that answer the questions your team actually asks.

If you want GA4 e-commerce tracking implemented correctly on your Odoo 19 store, we can do it. We audit your current tracking setup, implement the custom module, validate events in GA4 DebugView, configure your dashboards, and train your team to use them. The typical implementation takes 2-3 days and starts paying for itself within the first month of data-driven budget reallocation.

Book a Free Analytics Audit