{"slug":"call-attribution-ga4-ghl","title":"Call Attribution — UTM, GA4, GHL, and Multi-Touch Tracking","tags":["call-attribution","ga4","ghl","utm","tracking","dynamic-number-insertion"],"agent_summary":"Attribute phone calls to the marketing source that drove them: paid ads, organic search, direct, referral. Two primary techniques: dynamic number insertion (DNI) per-source, and session-stitching with stored cookies. Push attribution data to GA4 via Measurement Protocol, to GHL as conversation custom fields, and to revenue dashboards via webhooks.","trigger_phrases":["call attribution","dynamic number insertion","DNI","GA4 call tracking","GHL call attribution","UTM phone call","track calls from ads"],"runnable":false,"markdown":"\n# Call Attribution\n\nMost marketing spend is wasted because nobody knows which calls came from which campaign. Call attribution closes the loop: when a call comes in, the system can answer \"was this from Google Ads, organic SEO, Facebook, direct dial, or referral?\" — and if it converts to revenue, attribute that revenue back to the source.\n\n## The two attribution architectures\n\n### Dynamic Number Insertion (DNI)\n\nEach marketing source displays a different phone number. When a call comes in, you know the source by which number was dialed.\n\n```\nSource              → Display number      → All forward to: +13105550000\n─────────────────────────────────────────────────────────────────────────\nGoogle Ads campaign A → +13105551111\nGoogle Ads campaign B → +13105551112\nOrganic SEO          → +13105551113\nFacebook Ads         → +13105551114\nDirect/Print        → +13105550000 (the real number)\n```\n\n**Pros:**\n- Works for offline channels (print, billboards, direct mail)\n- Survives ad blockers\n- 100% attribution accuracy when properly configured\n\n**Cons:**\n- Number inventory (10+ numbers for a real campaign breakdown)\n- Per-number monthly lease cost ($1-3 each)\n- Doesn't capture session detail (keyword, landing page) — just \"which campaign\"\n\n### Session-stitching\n\nA single phone number is shown. A JavaScript tracker on the website captures session details (UTM, landing page, referrer, GCLID/FBCLID) and stores them in a cookie or local storage. When the user calls, the call event is correlated to their session by phone number, IP, or fingerprint.\n\n```\nVisitor → site (UTMs captured) → clicks tracked phone number → calls\n                                                                 ↓\n                                                  Backend correlates call to session\n```\n\n**Pros:**\n- One phone number, no inventory cost\n- Captures rich session detail (keyword, landing page, ad creative)\n- Multi-touch attribution possible\n\n**Cons:**\n- Offline channels can't be tracked\n- Cookie loss = attribution loss\n- Lower accuracy (typically 70-90%) due to multi-device, incognito, cleared cookies\n\n**Production reality:** Use both. DNI for offline + campaign-level, session-stitching for keyword-level.\n\n## DNI implementation\n\n### Per-source number purchasing\n\nIn SignalWire, buy numbers programmatically:\n\n```python\nnumbers = client.available_phone_numbers(\"US\").local.list(area_code=\"310\", limit=10)\nfor n in numbers[:5]:\n    purchased = client.incoming_phone_numbers.create(phone_number=n.phone_number)\n    db.execute(\"\"\"\n        INSERT INTO tracking_numbers (number, source, status)\n        VALUES ($1, 'unassigned', 'available')\n    \"\"\", purchased.phone_number)\n```\n\n### Pool-based DNI for paid ads\n\nInstead of dedicating a number per ad, rotate from a pool. When a visitor lands, JavaScript reserves a number from the pool for their session:\n\n```javascript\n// On page load\nfetch('/api/reserve-tracking-number', {\n    method: 'POST',\n    body: JSON.stringify({\n        utm_source: getCookie('utm_source'),\n        gclid: getCookie('gclid'),\n        session_id: getSessionId(),\n    })\n}).then(r => r.json()).then(data => {\n    // Replace all phone display elements with the reserved number\n    document.querySelectorAll('.phone-number').forEach(el => {\n        el.textContent = data.formatted;\n        el.href = 'tel:' + data.e164;\n    });\n});\n```\n\nBackend reserves a number from the pool for that session, releases it 30 minutes later (or 24 hours, depending on your policy):\n\n```python\n@app.route(\"/api/reserve-tracking-number\", methods=[\"POST\"])\ndef reserve():\n    payload = request.json\n    \n    # Find a number not currently reserved\n    number = db.fetch_one(\"\"\"\n        SELECT * FROM tracking_numbers\n        WHERE status='available'\n        ORDER BY last_used_at NULLS FIRST\n        LIMIT 1\n    \"\"\")\n    \n    db.execute(\"\"\"\n        UPDATE tracking_numbers\n        SET status='reserved',\n            reserved_for=$1,\n            reserved_session=$2,\n            reserved_at=now()\n        WHERE id=$3\n    \"\"\", payload, payload[\"session_id\"], number.id)\n    \n    return jsonify(\n        e164=number.number,\n        formatted=format_phone(number.number),\n    )\n```\n\n### Webhook on call → resolve attribution\n\nWhen a call comes in to a reserved number, look up the session that reserved it:\n\n```python\n@app.route(\"/incoming-call\", methods=[\"POST\"])\ndef incoming_call():\n    called = request.form[\"To\"]\n    \n    # Find the active reservation for this number\n    reservation = db.fetch_one(\"\"\"\n        SELECT * FROM tracking_numbers\n        WHERE number=$1 AND status='reserved'\n    \"\"\", called)\n    \n    attribution = reservation.reserved_for if reservation else {\"utm_source\": \"direct\"}\n    \n    # Save call with attribution\n    db.execute(\"\"\"\n        INSERT INTO calls (call_sid, caller, called, attribution, created_at)\n        VALUES ($1, $2, $3, $4, now())\n    \"\"\", request.form[\"CallSid\"], request.form[\"From\"], called, attribution)\n    \n    # Forward the call\n    return Response(f\"\"\"\n        <Response>\n            <Dial>+13105550000</Dial>\n        </Response>\n    \"\"\", mimetype=\"text/xml\")\n```\n\n## Session-stitching implementation\n\n### Capture UTMs on landing\n\n```javascript\nfunction captureAttribution() {\n    const params = new URLSearchParams(window.location.search);\n    const attribution = {\n        utm_source: params.get('utm_source'),\n        utm_medium: params.get('utm_medium'),\n        utm_campaign: params.get('utm_campaign'),\n        utm_content: params.get('utm_content'),\n        utm_term: params.get('utm_term'),\n        gclid: params.get('gclid'),\n        fbclid: params.get('fbclid'),\n        landing_page: window.location.href,\n        referrer: document.referrer,\n        timestamp: new Date().toISOString(),\n    };\n    \n    // First-touch (only if no existing)\n    if (!localStorage.getItem('first_touch')) {\n        localStorage.setItem('first_touch', JSON.stringify(attribution));\n    }\n    // Last-touch (always update)\n    localStorage.setItem('last_touch', JSON.stringify(attribution));\n}\ncaptureAttribution();\n```\n\n### Resolve attribution on call\n\nWhen the visitor calls, the system needs to correlate the call to the session. Two methods:\n\n**Method A: Browser ping when user clicks phone number**\n\n```javascript\ndocument.querySelectorAll('a[href^=\"tel:\"]').forEach(a => {\n    a.addEventListener('click', () => {\n        fetch('/api/click-to-call-event', {\n            method: 'POST',\n            body: JSON.stringify({\n                phone_clicked: a.href.replace('tel:', ''),\n                attribution: {\n                    first_touch: JSON.parse(localStorage.getItem('first_touch') || '{}'),\n                    last_touch: JSON.parse(localStorage.getItem('last_touch') || '{}'),\n                },\n                session_id: getSessionId(),\n            })\n        });\n    });\n});\n```\n\nBackend stores the click event with a TTL. When a call arrives within (say) 5 minutes from any phone in the visitor's area, match it.\n\n**Method B: Form-submit phone capture**\n\nIf the visitor enters their phone in a form before calling, you have an exact match. Best accuracy when feasible.\n\n## Pushing attribution to GA4\n\nGA4's Measurement Protocol accepts server-side events:\n\n```python\nimport requests\n\ndef push_to_ga4(call):\n    requests.post(\n        f\"https://www.google-analytics.com/mp/collect?measurement_id={GA4_ID}&api_secret={GA4_API_SECRET}\",\n        json={\n            \"client_id\": call.client_id or call.caller,\n            \"events\": [{\n                \"name\": \"phone_call\",\n                \"params\": {\n                    \"call_duration\": call.duration,\n                    \"call_outcome\": call.outcome,\n                    \"source\": call.attribution.utm_source,\n                    \"medium\": call.attribution.utm_medium,\n                    \"campaign\": call.attribution.utm_campaign,\n                    \"value\": call.estimated_value,\n                    \"currency\": \"USD\",\n                },\n            }],\n        },\n    )\n```\n\nIf the call converts (becomes a job won, sale made), push a separate `conversion` event with the revenue value. GA4 will attribute revenue back to the original source.\n\n## Pushing attribution to GHL\n\nGHL conversations can carry custom fields. When a call lands in GHL, populate fields:\n\n```python\ndef push_to_ghl(call, attribution):\n    # Find or create the contact\n    contact = ghl.contacts.upsert(\n        phone=call.caller,\n        custom_fields={\n            \"utm_source\": attribution.utm_source,\n            \"utm_medium\": attribution.utm_medium,\n            \"utm_campaign\": attribution.utm_campaign,\n            \"first_touch_landing\": attribution.first_touch.landing_page,\n            \"first_call_at\": call.created_at.isoformat(),\n            \"tracking_number_dialed\": call.called,\n        }\n    )\n    \n    # Log the call to the contact's timeline\n    ghl.calls.log(\n        contact_id=contact.id,\n        duration=call.duration,\n        direction=\"inbound\",\n        recording_url=call.recording_url,\n    )\n```\n\nGHL workflows can branch on attribution: \"if utm_source=google, assign to sales team A; if utm_source=facebook, assign to team B.\"\n\n## Multi-touch attribution\n\nFor high-value sales cycles with multiple touchpoints, capture every touch:\n\n```javascript\nfunction recordTouch() {\n    const touches = JSON.parse(localStorage.getItem('touches') || '[]');\n    touches.push(captureAttribution());\n    localStorage.setItem('touches', JSON.stringify(touches));\n}\n```\n\nWhen the call converts, your attribution model assigns credit:\n\n| Model | Credit assignment |\n|---|---|\n| First-touch | 100% to first touch |\n| Last-touch | 100% to last touch |\n| Linear | Equal split across all touches |\n| Time-decay | Recent touches weighted higher |\n| Position-based (U-shaped) | 40% first, 40% last, 20% split among middle |\n| Data-driven (GA4) | ML-derived weights |\n\nGA4's data-driven attribution is the default for most use cases — it learns from conversion paths.\n\n## Phone-to-conversion pipeline\n\nThe full attribution + conversion pipeline:\n\n```\n1. Visitor lands → JS captures attribution → stored client-side\n2. Visitor clicks phone link → click event stored server-side with attribution\n3. Visitor calls → incoming-call webhook resolves attribution\n4. Call event saved with attribution snapshot\n5. Call recorded → transcribed → analyzed for outcome (lead, customer, complaint)\n6. If lead → push to GHL with attribution\n7. If converted (job won) → push conversion event to GA4 with revenue + attribution\n8. GA4 reports show: \"Google Ads campaign X drove Y phone calls, Z conversions, $N revenue\"\n```\n\n## Reporting dashboard schema\n\nMinimum tables for an in-house attribution dashboard:\n\n```sql\nCREATE TABLE calls (\n    id UUID PRIMARY KEY,\n    call_sid TEXT UNIQUE,\n    caller TEXT,\n    called TEXT,\n    duration INT,\n    direction TEXT,\n    outcome TEXT,\n    attribution JSONB,\n    created_at TIMESTAMPTZ\n);\n\nCREATE TABLE conversions (\n    id UUID PRIMARY KEY,\n    call_id UUID REFERENCES calls(id),\n    converted_at TIMESTAMPTZ,\n    revenue NUMERIC,\n    type TEXT\n);\n\nCREATE TABLE attribution_paths (\n    contact_id UUID,\n    touch_index INT,\n    source TEXT,\n    medium TEXT,\n    campaign TEXT,\n    occurred_at TIMESTAMPTZ\n);\n```\n\nStandard reports:\n\n- Calls by source/medium/campaign\n- Cost per call by source (calls / ad spend)\n- Conversion rate by source\n- Revenue by source\n- Multi-touch path analysis (which paths convert?)\n\n## Privacy considerations\n\nStoring visitor attribution creates personal data. Implications:\n\n- **GDPR** — if any visitor is in the EU, you need a lawful basis (legitimate interest typically). Cookie banner + opt-out flow.\n- **CCPA** — California visitors get the right to delete attribution data.\n- **Avoid storing phone + GCLID together** without consent — Google's ad-personalization policy may apply.\n\nHash phone numbers in storage if you don't need to match outbound traffic.\n\n## Common pitfalls\n\n- **Pool-based DNI with too few numbers** — concurrent visitors get the same number, attribution scrambles. Rule of thumb: 1 number per 200 daily uniques minimum.\n- **Long reservation TTLs blocking the pool** — 24-hour reservations exhaust the pool fast. 30 minutes is typical.\n- **Not handling area-code mismatch** — visitor in NYC sees a 310 (LA) number. Suspicious. Use area-code-matching pools.\n- **Ignoring caller ID changes** — caller blocked their ID, attribution falls to \"unknown\". Capture as much non-PII context as possible.\n- **GA4 event missing client_id** — events appear in GA4 but don't tie to a user session. Always pass the client_id from the GA cookie.\n\n## Related patterns\n\n- [SignalWire call intelligence](/topic/signalwire-call-intelligence) — call recording + analysis pipeline\n- [Sentiment analysis pipeline](/topic/sentiment-analysis-pipeline) — call outcome classification\n- [Missed call workflows](/topic/missed-call-workflows) — attribution survives missed calls too\n\n## References\n\n- GA4 Measurement Protocol — server-side event documentation\n- GHL Marketplace API — contacts, conversations, custom fields\n- CallRail attribution architecture white paper (public)\n- Google Ads Conversions from Phone Calls — gclid-based attribution\n","html":"<h1>Call Attribution</h1>\n<p>Most marketing spend is wasted because nobody knows which calls came from which campaign. Call attribution closes the loop: when a call comes in, the system can answer \"was this from Google Ads, organic SEO, Facebook, direct dial, or referral?\" — and if it converts to revenue, attribute that revenue back to the source.</p>\n<h2>The two attribution architectures</h2>\n<h3>Dynamic Number Insertion (DNI)</h3>\n<p>Each marketing source displays a different phone number. When a call comes in, you know the source by which number was dialed.</p>\n<pre><code>Source              → Display number      → All forward to: +13105550000\n─────────────────────────────────────────────────────────────────────────\nGoogle Ads campaign A → +13105551111\nGoogle Ads campaign B → +13105551112\nOrganic SEO          → +13105551113\nFacebook Ads         → +13105551114\nDirect/Print        → +13105550000 (the real number)\n</code></pre>\n<p><strong>Pros:</strong></p>\n<ul>\n<li>Works for offline channels (print, billboards, direct mail)</li>\n<li>Survives ad blockers</li>\n<li>100% attribution accuracy when properly configured</li>\n</ul>\n<p><strong>Cons:</strong></p>\n<ul>\n<li>Number inventory (10+ numbers for a real campaign breakdown)</li>\n<li>Per-number monthly lease cost ($1-3 each)</li>\n<li>Doesn't capture session detail (keyword, landing page) — just \"which campaign\"</li>\n</ul>\n<h3>Session-stitching</h3>\n<p>A single phone number is shown. A JavaScript tracker on the website captures session details (UTM, landing page, referrer, GCLID/FBCLID) and stores them in a cookie or local storage. When the user calls, the call event is correlated to their session by phone number, IP, or fingerprint.</p>\n<pre><code>Visitor → site (UTMs captured) → clicks tracked phone number → calls\n                                                                 ↓\n                                                  Backend correlates call to session\n</code></pre>\n<p><strong>Pros:</strong></p>\n<ul>\n<li>One phone number, no inventory cost</li>\n<li>Captures rich session detail (keyword, landing page, ad creative)</li>\n<li>Multi-touch attribution possible</li>\n</ul>\n<p><strong>Cons:</strong></p>\n<ul>\n<li>Offline channels can't be tracked</li>\n<li>Cookie loss = attribution loss</li>\n<li>Lower accuracy (typically 70-90%) due to multi-device, incognito, cleared cookies</li>\n</ul>\n<p><strong>Production reality:</strong> Use both. DNI for offline + campaign-level, session-stitching for keyword-level.</p>\n<h2>DNI implementation</h2>\n<h3>Per-source number purchasing</h3>\n<p>In SignalWire, buy numbers programmatically:</p>\n<pre><code class=\"language-python\">numbers = client.available_phone_numbers(\"US\").local.list(area_code=\"310\", limit=10)\nfor n in numbers[:5]:\n    purchased = client.incoming_phone_numbers.create(phone_number=n.phone_number)\n    db.execute(\"\"\"\n        INSERT INTO tracking_numbers (number, source, status)\n        VALUES ($1, 'unassigned', 'available')\n    \"\"\", purchased.phone_number)\n</code></pre>\n<h3>Pool-based DNI for paid ads</h3>\n<p>Instead of dedicating a number per ad, rotate from a pool. When a visitor lands, JavaScript reserves a number from the pool for their session:</p>\n<pre><code class=\"language-javascript\">// On page load\nfetch('/api/reserve-tracking-number', {\n    method: 'POST',\n    body: JSON.stringify({\n        utm_source: getCookie('utm_source'),\n        gclid: getCookie('gclid'),\n        session_id: getSessionId(),\n    })\n}).then(r => r.json()).then(data => {\n    // Replace all phone display elements with the reserved number\n    document.querySelectorAll('.phone-number').forEach(el => {\n        el.textContent = data.formatted;\n        el.href = 'tel:' + data.e164;\n    });\n});\n</code></pre>\n<p>Backend reserves a number from the pool for that session, releases it 30 minutes later (or 24 hours, depending on your policy):</p>\n<pre><code class=\"language-python\">@app.route(\"/api/reserve-tracking-number\", methods=[\"POST\"])\ndef reserve():\n    payload = request.json\n    \n    # Find a number not currently reserved\n    number = db.fetch_one(\"\"\"\n        SELECT * FROM tracking_numbers\n        WHERE status='available'\n        ORDER BY last_used_at NULLS FIRST\n        LIMIT 1\n    \"\"\")\n    \n    db.execute(\"\"\"\n        UPDATE tracking_numbers\n        SET status='reserved',\n            reserved_for=$1,\n            reserved_session=$2,\n            reserved_at=now()\n        WHERE id=$3\n    \"\"\", payload, payload[\"session_id\"], number.id)\n    \n    return jsonify(\n        e164=number.number,\n        formatted=format_phone(number.number),\n    )\n</code></pre>\n<h3>Webhook on call → resolve attribution</h3>\n<p>When a call comes in to a reserved number, look up the session that reserved it:</p>\n<pre><code class=\"language-python\">@app.route(\"/incoming-call\", methods=[\"POST\"])\ndef incoming_call():\n    called = request.form[\"To\"]\n    \n    # Find the active reservation for this number\n    reservation = db.fetch_one(\"\"\"\n        SELECT * FROM tracking_numbers\n        WHERE number=$1 AND status='reserved'\n    \"\"\", called)\n    \n    attribution = reservation.reserved_for if reservation else {\"utm_source\": \"direct\"}\n    \n    # Save call with attribution\n    db.execute(\"\"\"\n        INSERT INTO calls (call_sid, caller, called, attribution, created_at)\n        VALUES ($1, $2, $3, $4, now())\n    \"\"\", request.form[\"CallSid\"], request.form[\"From\"], called, attribution)\n    \n    # Forward the call\n    return Response(f\"\"\"\n        &#x3C;Response>\n            &#x3C;Dial>+13105550000&#x3C;/Dial>\n        &#x3C;/Response>\n    \"\"\", mimetype=\"text/xml\")\n</code></pre>\n<h2>Session-stitching implementation</h2>\n<h3>Capture UTMs on landing</h3>\n<pre><code class=\"language-javascript\">function captureAttribution() {\n    const params = new URLSearchParams(window.location.search);\n    const attribution = {\n        utm_source: params.get('utm_source'),\n        utm_medium: params.get('utm_medium'),\n        utm_campaign: params.get('utm_campaign'),\n        utm_content: params.get('utm_content'),\n        utm_term: params.get('utm_term'),\n        gclid: params.get('gclid'),\n        fbclid: params.get('fbclid'),\n        landing_page: window.location.href,\n        referrer: document.referrer,\n        timestamp: new Date().toISOString(),\n    };\n    \n    // First-touch (only if no existing)\n    if (!localStorage.getItem('first_touch')) {\n        localStorage.setItem('first_touch', JSON.stringify(attribution));\n    }\n    // Last-touch (always update)\n    localStorage.setItem('last_touch', JSON.stringify(attribution));\n}\ncaptureAttribution();\n</code></pre>\n<h3>Resolve attribution on call</h3>\n<p>When the visitor calls, the system needs to correlate the call to the session. Two methods:</p>\n<p><strong>Method A: Browser ping when user clicks phone number</strong></p>\n<pre><code class=\"language-javascript\">document.querySelectorAll('a[href^=\"tel:\"]').forEach(a => {\n    a.addEventListener('click', () => {\n        fetch('/api/click-to-call-event', {\n            method: 'POST',\n            body: JSON.stringify({\n                phone_clicked: a.href.replace('tel:', ''),\n                attribution: {\n                    first_touch: JSON.parse(localStorage.getItem('first_touch') || '{}'),\n                    last_touch: JSON.parse(localStorage.getItem('last_touch') || '{}'),\n                },\n                session_id: getSessionId(),\n            })\n        });\n    });\n});\n</code></pre>\n<p>Backend stores the click event with a TTL. When a call arrives within (say) 5 minutes from any phone in the visitor's area, match it.</p>\n<p><strong>Method B: Form-submit phone capture</strong></p>\n<p>If the visitor enters their phone in a form before calling, you have an exact match. Best accuracy when feasible.</p>\n<h2>Pushing attribution to GA4</h2>\n<p>GA4's Measurement Protocol accepts server-side events:</p>\n<pre><code class=\"language-python\">import requests\n\ndef push_to_ga4(call):\n    requests.post(\n        f\"https://www.google-analytics.com/mp/collect?measurement_id={GA4_ID}&#x26;api_secret={GA4_API_SECRET}\",\n        json={\n            \"client_id\": call.client_id or call.caller,\n            \"events\": [{\n                \"name\": \"phone_call\",\n                \"params\": {\n                    \"call_duration\": call.duration,\n                    \"call_outcome\": call.outcome,\n                    \"source\": call.attribution.utm_source,\n                    \"medium\": call.attribution.utm_medium,\n                    \"campaign\": call.attribution.utm_campaign,\n                    \"value\": call.estimated_value,\n                    \"currency\": \"USD\",\n                },\n            }],\n        },\n    )\n</code></pre>\n<p>If the call converts (becomes a job won, sale made), push a separate <code>conversion</code> event with the revenue value. GA4 will attribute revenue back to the original source.</p>\n<h2>Pushing attribution to GHL</h2>\n<p>GHL conversations can carry custom fields. When a call lands in GHL, populate fields:</p>\n<pre><code class=\"language-python\">def push_to_ghl(call, attribution):\n    # Find or create the contact\n    contact = ghl.contacts.upsert(\n        phone=call.caller,\n        custom_fields={\n            \"utm_source\": attribution.utm_source,\n            \"utm_medium\": attribution.utm_medium,\n            \"utm_campaign\": attribution.utm_campaign,\n            \"first_touch_landing\": attribution.first_touch.landing_page,\n            \"first_call_at\": call.created_at.isoformat(),\n            \"tracking_number_dialed\": call.called,\n        }\n    )\n    \n    # Log the call to the contact's timeline\n    ghl.calls.log(\n        contact_id=contact.id,\n        duration=call.duration,\n        direction=\"inbound\",\n        recording_url=call.recording_url,\n    )\n</code></pre>\n<p>GHL workflows can branch on attribution: \"if utm_source=google, assign to sales team A; if utm_source=facebook, assign to team B.\"</p>\n<h2>Multi-touch attribution</h2>\n<p>For high-value sales cycles with multiple touchpoints, capture every touch:</p>\n<pre><code class=\"language-javascript\">function recordTouch() {\n    const touches = JSON.parse(localStorage.getItem('touches') || '[]');\n    touches.push(captureAttribution());\n    localStorage.setItem('touches', JSON.stringify(touches));\n}\n</code></pre>\n<p>When the call converts, your attribution model assigns credit:</p>\n<p>| Model | Credit assignment |\n|---|---|\n| First-touch | 100% to first touch |\n| Last-touch | 100% to last touch |\n| Linear | Equal split across all touches |\n| Time-decay | Recent touches weighted higher |\n| Position-based (U-shaped) | 40% first, 40% last, 20% split among middle |\n| Data-driven (GA4) | ML-derived weights |</p>\n<p>GA4's data-driven attribution is the default for most use cases — it learns from conversion paths.</p>\n<h2>Phone-to-conversion pipeline</h2>\n<p>The full attribution + conversion pipeline:</p>\n<pre><code>1. Visitor lands → JS captures attribution → stored client-side\n2. Visitor clicks phone link → click event stored server-side with attribution\n3. Visitor calls → incoming-call webhook resolves attribution\n4. Call event saved with attribution snapshot\n5. Call recorded → transcribed → analyzed for outcome (lead, customer, complaint)\n6. If lead → push to GHL with attribution\n7. If converted (job won) → push conversion event to GA4 with revenue + attribution\n8. GA4 reports show: \"Google Ads campaign X drove Y phone calls, Z conversions, $N revenue\"\n</code></pre>\n<h2>Reporting dashboard schema</h2>\n<p>Minimum tables for an in-house attribution dashboard:</p>\n<pre><code class=\"language-sql\">CREATE TABLE calls (\n    id UUID PRIMARY KEY,\n    call_sid TEXT UNIQUE,\n    caller TEXT,\n    called TEXT,\n    duration INT,\n    direction TEXT,\n    outcome TEXT,\n    attribution JSONB,\n    created_at TIMESTAMPTZ\n);\n\nCREATE TABLE conversions (\n    id UUID PRIMARY KEY,\n    call_id UUID REFERENCES calls(id),\n    converted_at TIMESTAMPTZ,\n    revenue NUMERIC,\n    type TEXT\n);\n\nCREATE TABLE attribution_paths (\n    contact_id UUID,\n    touch_index INT,\n    source TEXT,\n    medium TEXT,\n    campaign TEXT,\n    occurred_at TIMESTAMPTZ\n);\n</code></pre>\n<p>Standard reports:</p>\n<ul>\n<li>Calls by source/medium/campaign</li>\n<li>Cost per call by source (calls / ad spend)</li>\n<li>Conversion rate by source</li>\n<li>Revenue by source</li>\n<li>Multi-touch path analysis (which paths convert?)</li>\n</ul>\n<h2>Privacy considerations</h2>\n<p>Storing visitor attribution creates personal data. Implications:</p>\n<ul>\n<li><strong>GDPR</strong> — if any visitor is in the EU, you need a lawful basis (legitimate interest typically). Cookie banner + opt-out flow.</li>\n<li><strong>CCPA</strong> — California visitors get the right to delete attribution data.</li>\n<li><strong>Avoid storing phone + GCLID together</strong> without consent — Google's ad-personalization policy may apply.</li>\n</ul>\n<p>Hash phone numbers in storage if you don't need to match outbound traffic.</p>\n<h2>Common pitfalls</h2>\n<ul>\n<li><strong>Pool-based DNI with too few numbers</strong> — concurrent visitors get the same number, attribution scrambles. Rule of thumb: 1 number per 200 daily uniques minimum.</li>\n<li><strong>Long reservation TTLs blocking the pool</strong> — 24-hour reservations exhaust the pool fast. 30 minutes is typical.</li>\n<li><strong>Not handling area-code mismatch</strong> — visitor in NYC sees a 310 (LA) number. Suspicious. Use area-code-matching pools.</li>\n<li><strong>Ignoring caller ID changes</strong> — caller blocked their ID, attribution falls to \"unknown\". Capture as much non-PII context as possible.</li>\n<li><strong>GA4 event missing client_id</strong> — events appear in GA4 but don't tie to a user session. Always pass the client_id from the GA cookie.</li>\n</ul>\n<h2>Related patterns</h2>\n<ul>\n<li><a href=\"/topic/signalwire-call-intelligence\">SignalWire call intelligence</a> — call recording + analysis pipeline</li>\n<li><a href=\"/topic/sentiment-analysis-pipeline\">Sentiment analysis pipeline</a> — call outcome classification</li>\n<li><a href=\"/topic/missed-call-workflows\">Missed call workflows</a> — attribution survives missed calls too</li>\n</ul>\n<h2>References</h2>\n<ul>\n<li>GA4 Measurement Protocol — server-side event documentation</li>\n<li>GHL Marketplace API — contacts, conversations, custom fields</li>\n<li>CallRail attribution architecture white paper (public)</li>\n<li>Google Ads Conversions from Phone Calls — gclid-based attribution</li>\n</ul>\n"}