Google Ads → Odoo lead attribution

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.

Prerequisites

A — Google Ads (no code)

  1. Auto-tagging ON. Admin → Account settings → Auto-tagging.
  2. Google tag sitewide (AW-XXXXXXXXX), via Website → Configuration → Settings → Google Ads or custom <head>. Also writes the _gcl_aw cookie (gclid fallback).
  3. Account-level Final URL suffix (Admin → Account settings → Tracking):
    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.
  4. Conversion action → Website → page-load rule: URL starts with <domain>/contactus-thank-you (Odoo's default form redirect). Turn on enhanced conversions (hashed form email improves match rates).

B — Odoo module

B1. Fields on crm.lead

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)")

B2. Whitelist for public form writes

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'])

B3. Hidden inputs on the form

<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.

B4. Capture script

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.

B5. Deploy

Bump the manifest version (5th segment) so the migration runs; deploy; upgrade the module.

C — Verify

  1. Visit <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.
  2. DevTools: sessionStorage holds ai_gclid/ai_utm_term after landing; hidden inputs filled before submit; form.dataset.aiTracked === "1".
  3. After a few days of real traffic: leads carry source=google, medium=cpc, campaign=<slug> (native) + gclid/utm_term (custom). Cross-check count vs Ads conversions.

Optional — track which ad group / ad served

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.)

  1. Extend the Final URL suffix with ValueTrack params:
    ...&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.
  2. Readable ad-group names instead of IDs: define a custom parameter per ad group (Ad group → Settings → Ad URL options → {_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.
  3. Odoo side: repeat the utm_term pattern per extra param — field on 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.

Gotchas

  1. 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.
  2. Missing whitelist fails silently. Lead is created, fields empty. Check the whitelist before debugging JS.
  3. Don't duplicate native UTM. source/medium/campaign are captured server-side — only gclid and utm_term need custom capture.
  4. utm_term has no cookie fallback. sessionStorage only — lost if the visitor converts in a fresh session days later (gclid survives via cookie). Optional: mirror utm_term to a 90-day first-party cookie.
  5. Display paths ≠ real URLs. Vanity display path is fine, but the Final URL must be a live 200 page or Google disapproves ("Destination not working").
  6. Form must target crm.lead. On a mail-only form, none of this does anything.
  7. Landing-page redirects can strip the params. If the Final URL redirects (http→https, trailing slash, geo/language), gclid and UTMs may be dropped before the page loads. Point ads at the final, non-redirecting URL; verify with the suffix Test button + the address bar on the landing page.
  8. EU / consent-mode sites: the cookie fallback needs consent. Google Consent Mode blocks the _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.

What this unlocks

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.