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.
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:
| Setting | Location | Value | Why It Matters |
|---|---|---|---|
| Enhanced Measurement | Data Streams > Web > Enhanced Measurement | ON (all toggles) | Auto-tracks scrolls, outbound clicks, site search, file downloads |
| Google Signals | Data Settings > Data Collection | ON | Cross-device tracking, demographic data, remarketing audiences |
| Data Retention | Data Settings > Data Retention | 14 months | Default is 2 months — too short for seasonal e-commerce analysis |
| Internal Traffic | Data Streams > Configure Tag > Internal Traffic | Define office IPs | Exclude 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.
{
"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",
}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 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> 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.
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:
/** @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:
<!-- 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 Event | Odoo Page / Action | Trigger | Key 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_cart | Any page with Add to Cart button | Click event (JS) | Product, price, quantity |
remove_from_cart | /shop/cart | Click event (JS) | Product, price removed |
begin_checkout | /shop/checkout | Page load (QWeb) | Cart total, all line items |
purchase | /shop/confirmation | Page load (QWeb) | Transaction ID, total, tax, shipping, coupon, items |
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:
<!-- 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> 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.
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:
# 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_launchCross-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.
# 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}")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:
| Metric | GA4 Path | What It Tells You |
|---|---|---|
| Add-to-cart rate | Monetization > E-commerce > Add to carts / Sessions | Is your product page compelling enough? |
| Cart abandonment rate | Explore > Funnel (add_to_cart → begin_checkout → purchase) | Where in checkout are you losing buyers? |
| Revenue by source/medium | Acquisition > Traffic Acquisition (+ Revenue metric) | Which channels deliver revenue, not just traffic? |
| Average order value | Monetization > Overview > Average purchase revenue per user | Are promotions increasing volume but shrinking margin? |
Dashboard 2: Weekly Funnel (E-commerce Manager)
In GA4 Explore, create a Funnel exploration with these steps:
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).
5 GA4 + Odoo Integration Mistakes That Silently Corrupt Your Data
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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:
// ── 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",
});
}
});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:
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.
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.
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.
Optimization Metadata
Complete GA4 integration guide for Odoo 19 e-Commerce. Track e-commerce events, configure enhanced conversions, revenue attribution, and reporting dashboards.