T
Telephony SOPKnowledge Base
Search
← All topics

Call Attribution — UTM, GA4, GHL, and Multi-Touch Tracking

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.

call-attributionga4ghlutmtrackingdynamic-number-insertion
Agent trigger phrases: call attribution · dynamic number insertion · DNI · GA4 call tracking · GHL call attribution · UTM phone call · track calls from ads

Call Attribution

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.

The two attribution architectures

Dynamic Number Insertion (DNI)

Each marketing source displays a different phone number. When a call comes in, you know the source by which number was dialed.

Source              → Display number      → All forward to: +13105550000
─────────────────────────────────────────────────────────────────────────
Google Ads campaign A → +13105551111
Google Ads campaign B → +13105551112
Organic SEO          → +13105551113
Facebook Ads         → +13105551114
Direct/Print        → +13105550000 (the real number)

Pros:

  • Works for offline channels (print, billboards, direct mail)
  • Survives ad blockers
  • 100% attribution accuracy when properly configured

Cons:

  • Number inventory (10+ numbers for a real campaign breakdown)
  • Per-number monthly lease cost ($1-3 each)
  • Doesn't capture session detail (keyword, landing page) — just "which campaign"

Session-stitching

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.

Visitor → site (UTMs captured) → clicks tracked phone number → calls
                                                                 ↓
                                                  Backend correlates call to session

Pros:

  • One phone number, no inventory cost
  • Captures rich session detail (keyword, landing page, ad creative)
  • Multi-touch attribution possible

Cons:

  • Offline channels can't be tracked
  • Cookie loss = attribution loss
  • Lower accuracy (typically 70-90%) due to multi-device, incognito, cleared cookies

Production reality: Use both. DNI for offline + campaign-level, session-stitching for keyword-level.

DNI implementation

Per-source number purchasing

In SignalWire, buy numbers programmatically:

numbers = client.available_phone_numbers("US").local.list(area_code="310", limit=10)
for n in numbers[:5]:
    purchased = client.incoming_phone_numbers.create(phone_number=n.phone_number)
    db.execute("""
        INSERT INTO tracking_numbers (number, source, status)
        VALUES ($1, 'unassigned', 'available')
    """, purchased.phone_number)

Pool-based DNI for paid ads

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:

// On page load
fetch('/api/reserve-tracking-number', {
    method: 'POST',
    body: JSON.stringify({
        utm_source: getCookie('utm_source'),
        gclid: getCookie('gclid'),
        session_id: getSessionId(),
    })
}).then(r => r.json()).then(data => {
    // Replace all phone display elements with the reserved number
    document.querySelectorAll('.phone-number').forEach(el => {
        el.textContent = data.formatted;
        el.href = 'tel:' + data.e164;
    });
});

Backend reserves a number from the pool for that session, releases it 30 minutes later (or 24 hours, depending on your policy):

@app.route("/api/reserve-tracking-number", methods=["POST"])
def reserve():
    payload = request.json
    
    # Find a number not currently reserved
    number = db.fetch_one("""
        SELECT * FROM tracking_numbers
        WHERE status='available'
        ORDER BY last_used_at NULLS FIRST
        LIMIT 1
    """)
    
    db.execute("""
        UPDATE tracking_numbers
        SET status='reserved',
            reserved_for=$1,
            reserved_session=$2,
            reserved_at=now()
        WHERE id=$3
    """, payload, payload["session_id"], number.id)
    
    return jsonify(
        e164=number.number,
        formatted=format_phone(number.number),
    )

Webhook on call → resolve attribution

When a call comes in to a reserved number, look up the session that reserved it:

@app.route("/incoming-call", methods=["POST"])
def incoming_call():
    called = request.form["To"]
    
    # Find the active reservation for this number
    reservation = db.fetch_one("""
        SELECT * FROM tracking_numbers
        WHERE number=$1 AND status='reserved'
    """, called)
    
    attribution = reservation.reserved_for if reservation else {"utm_source": "direct"}
    
    # Save call with attribution
    db.execute("""
        INSERT INTO calls (call_sid, caller, called, attribution, created_at)
        VALUES ($1, $2, $3, $4, now())
    """, request.form["CallSid"], request.form["From"], called, attribution)
    
    # Forward the call
    return Response(f"""
        <Response>
            <Dial>+13105550000</Dial>
        </Response>
    """, mimetype="text/xml")

Session-stitching implementation

Capture UTMs on landing

function captureAttribution() {
    const params = new URLSearchParams(window.location.search);
    const attribution = {
        utm_source: params.get('utm_source'),
        utm_medium: params.get('utm_medium'),
        utm_campaign: params.get('utm_campaign'),
        utm_content: params.get('utm_content'),
        utm_term: params.get('utm_term'),
        gclid: params.get('gclid'),
        fbclid: params.get('fbclid'),
        landing_page: window.location.href,
        referrer: document.referrer,
        timestamp: new Date().toISOString(),
    };
    
    // First-touch (only if no existing)
    if (!localStorage.getItem('first_touch')) {
        localStorage.setItem('first_touch', JSON.stringify(attribution));
    }
    // Last-touch (always update)
    localStorage.setItem('last_touch', JSON.stringify(attribution));
}
captureAttribution();

Resolve attribution on call

When the visitor calls, the system needs to correlate the call to the session. Two methods:

Method A: Browser ping when user clicks phone number

document.querySelectorAll('a[href^="tel:"]').forEach(a => {
    a.addEventListener('click', () => {
        fetch('/api/click-to-call-event', {
            method: 'POST',
            body: JSON.stringify({
                phone_clicked: a.href.replace('tel:', ''),
                attribution: {
                    first_touch: JSON.parse(localStorage.getItem('first_touch') || '{}'),
                    last_touch: JSON.parse(localStorage.getItem('last_touch') || '{}'),
                },
                session_id: getSessionId(),
            })
        });
    });
});

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.

Method B: Form-submit phone capture

If the visitor enters their phone in a form before calling, you have an exact match. Best accuracy when feasible.

Pushing attribution to GA4

GA4's Measurement Protocol accepts server-side events:

import requests

def push_to_ga4(call):
    requests.post(
        f"https://www.google-analytics.com/mp/collect?measurement_id={GA4_ID}&api_secret={GA4_API_SECRET}",
        json={
            "client_id": call.client_id or call.caller,
            "events": [{
                "name": "phone_call",
                "params": {
                    "call_duration": call.duration,
                    "call_outcome": call.outcome,
                    "source": call.attribution.utm_source,
                    "medium": call.attribution.utm_medium,
                    "campaign": call.attribution.utm_campaign,
                    "value": call.estimated_value,
                    "currency": "USD",
                },
            }],
        },
    )

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

Pushing attribution to GHL

GHL conversations can carry custom fields. When a call lands in GHL, populate fields:

def push_to_ghl(call, attribution):
    # Find or create the contact
    contact = ghl.contacts.upsert(
        phone=call.caller,
        custom_fields={
            "utm_source": attribution.utm_source,
            "utm_medium": attribution.utm_medium,
            "utm_campaign": attribution.utm_campaign,
            "first_touch_landing": attribution.first_touch.landing_page,
            "first_call_at": call.created_at.isoformat(),
            "tracking_number_dialed": call.called,
        }
    )
    
    # Log the call to the contact's timeline
    ghl.calls.log(
        contact_id=contact.id,
        duration=call.duration,
        direction="inbound",
        recording_url=call.recording_url,
    )

GHL workflows can branch on attribution: "if utm_source=google, assign to sales team A; if utm_source=facebook, assign to team B."

Multi-touch attribution

For high-value sales cycles with multiple touchpoints, capture every touch:

function recordTouch() {
    const touches = JSON.parse(localStorage.getItem('touches') || '[]');
    touches.push(captureAttribution());
    localStorage.setItem('touches', JSON.stringify(touches));
}

When the call converts, your attribution model assigns credit:

| Model | Credit assignment | |---|---| | First-touch | 100% to first touch | | Last-touch | 100% to last touch | | Linear | Equal split across all touches | | Time-decay | Recent touches weighted higher | | Position-based (U-shaped) | 40% first, 40% last, 20% split among middle | | Data-driven (GA4) | ML-derived weights |

GA4's data-driven attribution is the default for most use cases — it learns from conversion paths.

Phone-to-conversion pipeline

The full attribution + conversion pipeline:

1. Visitor lands → JS captures attribution → stored client-side
2. Visitor clicks phone link → click event stored server-side with attribution
3. Visitor calls → incoming-call webhook resolves attribution
4. Call event saved with attribution snapshot
5. Call recorded → transcribed → analyzed for outcome (lead, customer, complaint)
6. If lead → push to GHL with attribution
7. If converted (job won) → push conversion event to GA4 with revenue + attribution
8. GA4 reports show: "Google Ads campaign X drove Y phone calls, Z conversions, $N revenue"

Reporting dashboard schema

Minimum tables for an in-house attribution dashboard:

CREATE TABLE calls (
    id UUID PRIMARY KEY,
    call_sid TEXT UNIQUE,
    caller TEXT,
    called TEXT,
    duration INT,
    direction TEXT,
    outcome TEXT,
    attribution JSONB,
    created_at TIMESTAMPTZ
);

CREATE TABLE conversions (
    id UUID PRIMARY KEY,
    call_id UUID REFERENCES calls(id),
    converted_at TIMESTAMPTZ,
    revenue NUMERIC,
    type TEXT
);

CREATE TABLE attribution_paths (
    contact_id UUID,
    touch_index INT,
    source TEXT,
    medium TEXT,
    campaign TEXT,
    occurred_at TIMESTAMPTZ
);

Standard reports:

  • Calls by source/medium/campaign
  • Cost per call by source (calls / ad spend)
  • Conversion rate by source
  • Revenue by source
  • Multi-touch path analysis (which paths convert?)

Privacy considerations

Storing visitor attribution creates personal data. Implications:

  • GDPR — if any visitor is in the EU, you need a lawful basis (legitimate interest typically). Cookie banner + opt-out flow.
  • CCPA — California visitors get the right to delete attribution data.
  • Avoid storing phone + GCLID together without consent — Google's ad-personalization policy may apply.

Hash phone numbers in storage if you don't need to match outbound traffic.

Common pitfalls

  • 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.
  • Long reservation TTLs blocking the pool — 24-hour reservations exhaust the pool fast. 30 minutes is typical.
  • Not handling area-code mismatch — visitor in NYC sees a 310 (LA) number. Suspicious. Use area-code-matching pools.
  • Ignoring caller ID changes — caller blocked their ID, attribution falls to "unknown". Capture as much non-PII context as possible.
  • 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.

Related patterns

References

  • GA4 Measurement Protocol — server-side event documentation
  • GHL Marketplace API — contacts, conversations, custom fields
  • CallRail attribution architecture white paper (public)
  • Google Ads Conversions from Phone Calls — gclid-based attribution