Register campaign, keyword, and click ID (gclid) on every crm.lead created from an Odoo website contact form. Odoo 17–19.
Chain: auto-tagging puts gclid in the landing URL (mirrored into the 90-day _gcl_aw cookie by the Google tag). A Final URL suffix adds utm_source/medium/campaign + the keyword via ValueTrack {keyword} → utm_term. Odoo captures source/medium/campaign natively. Custom work = gclid + utm_term only: two fields, two hidden inputs, one JS file.
crm.lead records (website_crm installed, Website settings → Contact Form → create leads). Mail-only forms: fix first.AW-XXXXXXXXX), via Website → Configuration → Settings → Google Ads or custom <head>. Also writes the _gcl_aw cookie (gclid fallback).utm_source=google&utm_medium=cpc&utm_campaign=<campaign-slug>&utm_term={keyword}&utm_matchtype={matchtype}
Use the built-in Test button. Odoo matches/creates utm.campaign by name.contactus-thank-you (Odoo's form-redirect target). Contains, not starts with — on a multilingual site only the default language serves unprefixed (/contactus-thank-you), other languages get a prefix (/nl/contactus-thank-you), and a starts-with rule misses them. Turn on enhanced conversions (hashed form email improves match rates).from odoo import fields, models
class CrmLead(models.Model):
_inherit = "crm.lead"
gclid = fields.Char(string="Google Click ID", index=True)
utm_term = fields.Char(string="Ad Keyword (utm_term)")
Odoo blacklists all fields from public form writes by default — without this, the inputs are silently ignored.
Fresh installs — post_init_hook (declare in manifest):
def _post_init_hook(env):
env['ir.model.fields'].formbuilder_whitelist('crm.lead', ['gclid', 'utm_term'])
Existing DBs — migrations/<new-version>/post-migration.py (idempotent):
from odoo import SUPERUSER_ID, api
def migrate(cr, version):
env = api.Environment(cr, SUPERUSER_ID, {})
env['ir.model.fields'].formbuilder_whitelist('crm.lead', ['gclid', 'utm_term'])
<div class="s_website_form_field s_website_form_dnone" data-type="char" data-name="Field">
<input type="hidden" class="form-control s_website_form_input" name="gclid"/>
</div>
<div class="s_website_form_field s_website_form_dnone" data-type="char" data-name="Field">
<input type="hidden" class="form-control s_website_form_input" name="utm_term"/>
</div>
Editor-built forms: add the same hidden fields there; names must match the model fields exactly.
static/src/js/tracking.js, in web.assets_frontend:
(function () {
"use strict";
const readCookie = (name) => {
const m = document.cookie.match("(^|;)\\s*" + name + "\\s*=\\s*([^;]+)");
return m ? m.pop() : "";
};
const getGclid = () => {
const fromUrl = new URLSearchParams(window.location.search).get("gclid");
if (fromUrl) return fromUrl;
// `_gcl_aw` cookie format: "GCL.<timestamp>.<gclid>"
const gcl = readCookie("_gcl_aw");
if (gcl) {
const parts = gcl.split(".");
if (parts.length >= 3) return parts.slice(2).join(".");
}
return "";
};
const getUtmTerm = () =>
new URLSearchParams(window.location.search).get("utm_term") || "";
const safeGetStored = (key) => {
try { return sessionStorage.getItem(key) || ""; } catch (_e) { return ""; }
};
// Stash on every page load so values survive the internal hop
// from landing page to contact page.
try {
const term = getUtmTerm();
const gclid = getGclid();
if (gclid) sessionStorage.setItem("ai_gclid", gclid);
if (term) sessionStorage.setItem("ai_utm_term", term);
} catch (_e) { /* storage blocked — degrade silently */ }
const fillHiddenAttribution = (form) => {
const values = {
gclid: safeGetStored("ai_gclid") || getGclid(),
utm_term: safeGetStored("ai_utm_term") || getUtmTerm(),
};
for (const [name, value] of Object.entries(values)) {
if (!value) continue;
const input = form.querySelector('input[name="' + name + '"]');
if (input) input.value = value;
}
};
const attachFormTracking = () => {
const form = document.querySelector("form.s_website_form");
if (!form || form.dataset.aiTracked === "1") return;
form.dataset.aiTracked = "1";
fillHiddenAttribution(form);
form.addEventListener("submit", () => fillHiddenAttribution(form));
};
// CRITICAL — never bind only to DOMContentLoaded (gotcha 1).
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", attachFormTracking);
} else {
attachFormTracking();
}
document.addEventListener("website.content.updated", attachFormTracking);
})();
Multiple website forms on one page? Swap the querySelector in attachFormTracking for querySelectorAll + a loop.
Bump the manifest version (5th segment) so the migration runs; deploy; upgrade the module.
<landing-page>?gclid=TEST123&utm_term=testkw, navigate to the contact page via an internal link (proves the sessionStorage hop), submit → lead must show gclid=TEST123, utm_term=testkw. Delete the test lead.ai_gclid/ai_utm_term after landing; hidden inputs filled before submit; form.dataset.aiTracked === "1".source=google, medium=cpc, campaign=<slug> (native) + gclid/utm_term (custom). Cross-check count vs Ads conversions.Needed when keywords are shared across ad groups — the keyword alone doesn't say which theme or ad won. (Google already attributes conversions to the exact ad in the Ads UI via gclid; this is for pivoting lead quality inside Odoo.)
...&utm_term={keyword}&utm_matchtype={matchtype}&utm_content={creative}&adgroupid={adgroupid}
{creative} = the ad's numeric ID, {adgroupid} = the ad group's numeric ID. Both resolve at click time.{_adgroup}=partner-benelux) and use adgroup={_adgroup} in the suffix. One-time per ad group; a new ad group without the param resolves to empty — add it to the new-ad-group checklist. No naming trick exists for individual ads; live with the {creative} ID.crm.lead (e.g. utm_content, ads_adgroup), add to the formbuilder_whitelist call, hidden input, one more line in the JS values map. Same sessionStorage-hop behaviour.The same pattern extends to any ValueTrack param ({device}, {network}, {loc_physical_ms}) — only add what you'll actually pivot on.
web.assets_frontend JS runs after DOMContentLoaded. Odoo's lazy sub-bundle loads after window.load; a DOMContentLoaded listener never fires, website.content.updated only fires in the builder. Symptom: deployed but inputs stay empty. Always use the readyState guard.crm.lead. On a mail-only form, none of this does anything._gcl_aw cookie until the visitor accepts — URL capture on the landing page still works, but the return-later gclid fallback silently doesn't for non-consenting visitors./en/...-style URLs for the default language only redirect (see gotcha 7).With gclid on the lead: offline conversion import — upload gclid + conversion time when a lead qualifies or a deal is won, so Smart Bidding optimizes on lead quality instead of raw form-fills.