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
- SignalWire call intelligence — call recording + analysis pipeline
- Sentiment analysis pipeline — call outcome classification
- Missed call workflows — attribution survives missed calls too
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