Operations & Data · Draft
UTM & Attribution Spec — Per-Vendor, GA4 Match Strategy
Goal: Near 100% match between GA4 (source of truth) and ad platform reporting at campaign level (then ad/creative).
Two tracks:
| Track | Vendors | URL parameters |
|---|---|---|
| A — Auto-tagging | Google Ads (all products) | No utm_* — gclid + GA4 ↔ Ads link only |
| B — UTM macros | Meta, TikTok, DV360 | Full utm_* schema with dynamic macros |
CRM SKU (Google only): add kcid / kpv custom parameters (no utm_ prefix) on top of auto-tagging — for form capture and offline match. Paste instructions in implementation guide.
Related: Data & tracking · GA4 source of truth · Campaign execution · Reporting
Principles
| # | Rule |
|---|---|
| 1 | Google: no utm_* when auto-tagging is on — avoids collision with Google's auto-tagging and GA4 Ads import |
| 2 | Social: campaign ID in utm_campaign — macro-first ({{campaign.id}}, __CAMPAIGN_ID__); system-inject at mutate if macros unavailable |
| 3 | Campaign ID first for joins — platform campaign_id, then click ID, then normalized name |
| 4 | CRM on Google — capture gclid + custom params (kcid, kpv) only — never utm_* on Google Ads URLs or CRM fields for Google-sourced leads |
| 5 | CRM on Meta/TikTok — capture fbclid/ttclid + utm_* from landing URL (macros resolve to platform IDs) |
| 6 | QC on every mutate — Google: auto-tagging + no forbidden utm_; Social: validated UTM template |
Canonical contracts
Track A — Google Ads (no utm_*)
| Mechanism | Purpose |
|---|---|
Auto-tagging (gclid, gbraid, wbraid) |
Primary click ↔ campaign join |
| GA4 ↔ Google Ads link | googleAdsCampaignId, cost, campaign name in GA4 |
| Kobi Google Ads BQ export | Warehouse join on campaign.id |
Custom params (kcid, kpv) — CRM SKU only |
Internal plan/manifest key on landing URL + forms — not utm_ prefix |
GA4 knows Google traffic from Google Ads data source + session gclid — not from utm_*.
Track B — Meta / TikTok / DV360 (utm_* schema)
| Parameter | Semantics | GA4 join priority |
|---|---|---|
utm_source |
Vendor channel slug (fixed enum) | Channel validation |
utm_medium |
paid_social, display, video |
Channel validation |
utm_campaign |
Platform campaign ID — macro or injected | Join key #1 |
utm_content |
Platform ad / creative ID | Join key #2 |
utm_term |
Ad set / keyword / placement — optional | Drill-down |
| Vendor | utm_source |
utm_medium |
|---|---|---|
| Meta (FB / IG) | {{site_source_name}} or facebook / instagram |
paid_social |
| TikTok | tiktok |
paid_social |
| DV360 | dv360 |
display |
GA4 custom dimensions (all tenants)
| Dimension | Google source | Social source |
|---|---|---|
platform_campaign_id |
Ads link / BQ / gclid resolution |
utm_campaign |
platform_ad_id |
BQ ad.id via click join |
utm_content |
kobi_campaign_key |
kcid custom param (CRM) or manifest |
kcid or utm_* + manifest |
kobi_plan_version |
kpv custom param |
kpv or event scope |
kobi_tenant_id |
Registry | Registry |
Execution decision tree
Social: never static human slug in utm_campaign. Google: never any utm_* parameter on ads URLs.
Per-vendor specification
Google Ads — auto-tagging only (no utm_*)
| Item | Detail |
|---|---|
| Primary attribution | Auto-tagging (gclid) + GA4 ↔ Google Ads link + Kobi Ads BQ |
utm_* on ads |
Forbidden — auto-tagging is active; UTMs add noise and can conflict with GA4 Google Ads integration |
| Default URL | Final URL = landing page only (no tracking template with utm_) |
| CRM SKU add-on | Custom parameters only — see below |
All Google products (Search, Display, Shopping, PMax, YouTube, Demand Gen): same rule — no utm_*.
| Ad type | Join path |
|---|---|
| Search / Display / Shopping | gclid + GA4 Ads link + BQ campaign.id |
| Performance Max | gclid + BQ campaign ID; asset group in manifest for ops — not via UTM |
| YouTube | Same auto-tagging |
CRM SKU — custom parameters (paste on forms + optional URL suffix)
Use non-utm_ query params so auto-tagging stays clean:
| Param | Set by | Value | CRM / form field |
|---|---|---|---|
kcid |
Execution | logical_campaign_key (UUID) |
Hidden input — paste from guide |
kpv |
Execution | plan_version integer |
Hidden input |
Optional final_url_suffix (CRM SKU only) — campaign level:
kcid={_kcid}&kpv={_kpv}
Define custom parameters on campaign / ad group in Google Ads API (custom_parameters or url_custom_parameters):
| Custom param | Value at mutate |
|---|---|
_kcid |
logical_campaign_key from plan |
_kpv |
plan_version |
Implementation guide §: “Add hidden fields gclid (auto from URL), kcid, kpv — do not add utm_* fields for Google traffic.”
API surfaces (execution):
| Object | Field | Action |
|---|---|---|
| Customer | auto_tagging_enabled |
true — required |
| Customer / Campaign | tracking_url_template |
Empty or {lpurl} only — no utm_ |
| Campaign | final_url_suffix |
kcid/kpv suffix if CRM SKU |
| Campaign | url_custom_parameters |
_kcid, _kpv if CRM SKU |
Onboarding: enable auto_tagging_enabled ✅; verify GA4 ↔ Ads link when client grants Admin (cross-check).
Meta (Facebook / Instagram)
| Item | Detail |
|---|---|
| Primary attribution | UTM + fbclid — no native GA4 auto-link like Google |
| Macro support | ✅ Dynamic URL parameters on ad / ad set |
| Apply level | Ad set URL tags or ad website_url parameters |
Recommended URL tags (ad set or account):
utm_source={{site_source_name}}&utm_medium=paid_social&utm_campaign={{campaign.id}}&utm_content={{ad.id}}&utm_term={{adset.id}}
| Meta macro | Maps to | Notes |
|---|---|---|
{{campaign.id}} |
utm_campaign |
Required |
{{ad.id}} |
utm_content |
Ad-level |
{{adset.id}} |
utm_term or secondary content |
Ad set drill-down |
{{site_source_name}} |
utm_source |
facebook vs instagram automatically |
{{campaign.name}} |
Do not use for join | Diagnostic only if needed in unused param |
Fallback (no macros — rare):
After POST ad create, set creative.link_data.link / asset_feed_spec URLs with:
utm_campaign={campaign_id}&utm_content={ad_id} from API response.
Advantage+ / dynamic creative: macros still apply on served ad; verify in Ads Manager preview. If URL frozen at creative bundle level → system regenerate on each creative variant ID.
TikTok Ads
| Item | Detail |
|---|---|
| Primary attribution | UTM + ttclid |
| Macro support | ✅ TikTok URL parameters |
| Apply level | Ad / ad group URL field |
Recommended URL suffix:
utm_source=tiktok&utm_medium=paid_social&utm_campaign=__CAMPAIGN_ID__&utm_content=__CID__&utm_term=__AID__
| TikTok macro | Maps to |
|---|---|
__CAMPAIGN_ID__ |
utm_campaign |
__CID__ |
utm_content (creative) |
__AID__ |
utm_term (ad group) |
__CAMPAIGN_NAME__ |
Do not use for join |
Fallback: post-create inject from campaign_id, adgroup_id, ad_id fields on ad object.
DV360 (deferred SKU)
| Item | Detail |
|---|---|
| Macros | ✅ Campaign Manager / DV360 placement macros (%ebuy!, %eaid!, etc.) — pin at implementation |
| GA4 | UTMs on all click-through; Floodlight optional — GA4 SoT per dv360.md |
| Join | Campaign / insertion order ID → utm_campaign via macro or system suffix |
Execution service responsibilities
At apply(plan_version) (campaign execution):
| Step | Owner |
|---|---|
1. Resolve logical_campaign_key → platform create mutate |
Execution connector |
2. Google: ensure auto-tagging; apply kcid/kpv suffix if CRM SKU — no utm_* |
Execution connector |
| 2b. Social: apply URL tags / templates with utm_ macros* | Execution connector |
| 3. Social only: if macro unsupported, patch final URL with IDs from mutate response | Execution connector |
4. Persist platform_campaign_id, tracking_mode, template hash on execution_manifest |
Registry |
5. Run tracking QC (deterministic) — Google: no utm_; Social: valid macros |
QC |
6. Emit tracking.applied audit event |
Orchestrator |
execution_manifest tracking slice (illustrative)
Google (CRM SKU):
{
"logical_campaign_key": "health_search_brand_v3",
"platform": "google_ads",
"platform_campaign_id": "12345678901",
"tracking_mode": "auto_tagging",
"final_url_suffix": "kcid={_kcid}&kpv={_kpv}",
"custom_parameters": { "_kcid": "uuid", "_kpv": "3" },
"utm_forbidden": true
}
Meta:
{
"platform": "meta",
"platform_campaign_id": "120210...",
"tracking_mode": "macro",
"url_tags": "utm_source={{site_source_name}}&utm_medium=paid_social&utm_campaign={{campaign.id}}&utm_content={{ad.id}}"
}
GA4 ↔ platform match strategy (near 100%)
Join hierarchy (use in order — stop at first hit)
| Priority | Key | Google Ads | Meta | TikTok |
|---|---|---|---|---|
| 1 | Platform campaign ID | googleAdsCampaignId (GA4 Ads link) / BQ campaign.id |
utm_campaign = {{campaign.id}} |
utm_campaign = __CAMPAIGN_ID__ |
| 2 | Click ID | gclid ↔ click report |
fbclid |
ttclid |
| 3 | Linked / warehouse | Kobi Google Ads BQ join | — | — |
| 4 | Platform campaign name | Normalized session_campaign = ads campaign.name |
Same | Same |
| 5 | Ad / creative ID | BQ ad.id via gclid |
utm_content ↔ ad.id |
utm_content ↔ creative |
| 6 | Internal key | kcid custom param (CRM) |
kcid or manifest via UTM IDs |
Same |
Google does not use utm_campaign in join logic.
Normalization for name fallback: lowercase, trim, collapse spaces, strip emoji, remove | Kobi suffixes from auto-naming.
Target match rates (rolling 7d, campaign grain)
| Pair | Target | Alert |
|---|---|---|
GA4 sessions ↔ Google Ads (linked + gclid, no UTM) |
≥ 98% | < 95% |
| GA4 UTM-attributed conversions ↔ Meta Ads | ≥ 95% | < 90% |
| GA4 ↔ TikTok Ads | ≥ 95% | < 90% |
CRM leads with click ID ↔ GA4 generate_lead |
≥ 90% | < 80% |
Unmatched volume → attribution_gap reason codes: missing_gclid, ads_link_broken, missing_utm (social only), name_mismatch, pmax_granularity, consent_denied, redirect_strip.
Reporting join flow
Optimization rule: bid and budget decisions use GA4 metrics joined on campaign ID; platform UI conversions are diagnostic only (ga4-source-of-truth.md).
QC checklist (deterministic — not LLM)
Google Ads
| Check | Fail action |
|---|---|
auto_tagging_enabled = true |
Block campaign.live |
No utm_ substring in tracking template, final URL suffix, or ad URLs |
Block |
CRM SKU: kcid/kpv present in suffix or custom params |
Warn if CRM SKU |
| GA4 ↔ Ads link active (if GA4 Admin granted) | Warn |
Test click: gclid on landing URL |
Block |
Meta / TikTok / DV360
| Check | Fail action |
|---|---|
utm_source + utm_medium + utm_campaign present |
Block |
utm_campaign = macro or numeric platform campaign ID |
Block |
Not human slug alone in utm_campaign |
Block |
Redirect preserves utm_* and click IDs (landing probe) |
HITL |
GA4 DebugView: utm_campaign matches platform ID after test click |
Warn |
CRM & offline alignment
Google-sourced leads (CRM SKU)
| Form / CRM field | Source | Notes |
|---|---|---|
gclid |
Auto on landing URL from Google click | Required for offline import |
kcid |
Custom param kcid on URL |
Paste hidden field — implementation guide |
kpv |
Custom param kpv on URL |
Plan version |
utm_* |
Do not capture for Google traffic | Auto-tagging handles attribution |
Meta / TikTok-sourced leads
| Field | Source |
|---|---|
fbclid / ttclid |
Landing URL |
utm_campaign |
Platform campaign ID (from macros) |
utm_content |
Ad ID |
kcid / kpv |
Optional custom params (same as Google) |
Offline upload: click ID first, then hashed PII. Google offline conversions use gclid only — not utm_*.
Implementation guide deliverable (onboarding)
| Section | Content |
|---|---|
Auto-tagging explained; no UTM; CRM forms: paste gclid + kcid + kpv hidden fields |
|
| Meta / TikTok | Full utm_ macro* templates; Kobi execution applies automatically |
| GA4 | Custom dimensions; Google via Ads link; social via utm_campaign |
| Test | Google: verify gclid + linked campaign in GA4; Social: verify utm_campaign = platform ID |
| Do not | Add utm_* to Google Ads; override social URL tags without ops approval |