Lead Attribution
How a single lead is bucketed into google_ads / gbp / organic_seo / social_media / referral / direct / other.
Why attribution is hard
A lead can come from many touchpoints — saw a Google Ad, then searched the brand on Google, then clicked the GBP profile, then called. Attribution is reductive by necessity. FPM Client Hub uses a simple deterministic rule: whatever string sits on contact.source in GHL determines the bucket.
That makes accuracy dependent on intake hygiene at the agency, not on the dashboard. Most attribution issues you will see are intake issues.
The standard buckets
| Bucket | Matches contact.source containing |
|---|---|
| google_ads | "google" + ("ads" or "paid"), or known UTM patterns like utm_medium=cpc |
| gbp | "gbp", "google business", "business profile", "maps" |
| organic_seo | "organic", "seo" |
| social_media | "facebook", "instagram", "meta", "tiktok", "linkedin", "youtube" |
| direct_referral | "referral" |
| direct | "direct" (typed URL or no referrer) |
| other | anything not matching above |
The bucketing function
Lives at the top of /api/metrics/ghl/route.ts:
function bucketSource(raw: string): string {
const s = (raw ?? '').toLowerCase()
if (s.includes('google') && (s.includes('ads') || s.includes('paid') || s.includes('cpc'))) return 'google_ads'
if (s.includes('gbp') || s.includes('business profile') || s.includes('maps')) return 'gbp'
if (s.includes('organic') || s.includes('seo')) return 'organic_seo'
if (s.includes('facebook') || s.includes('instagram') || s.includes('meta')
|| s.includes('tiktok') || s.includes('linkedin') || s.includes('youtube')) return 'social_media'
if (s.includes('referral')) return 'direct_referral'
if (s.includes('direct')) return 'direct'
return 'other'
}contact.source values and either (a) clean up the intake to land in an existing bucket or (b) add a new bucket here if a real new channel emerged.Leads, Appointments, Sales — three parallel breakdowns
We compute three separate bySource maps:
leadsBySource— counted off contact creation. Determines "Leads by Source" card.appointmentsBySource— when a contact has at least one appointment in the date range, it counts under that contact's source bucket.salesBySource— same logic but for won opportunities.
These power the Summary tab's "Leads / Appointments by Source" grid in the Client Portal.
Platform-level overrides
For Google Ads, Meta Ads, and GBP, we trust the platform metric over the GHL attribution where the platform is more reliable:
| Bucket | Override source |
|---|---|
| google_ads | Google Ads conversions count (preferred over GHL contact source where googleAdsData is connected) |
| social_media (paid) | Meta Ads leads action count |
| gbp | GBP Performance API actions: calls + websiteClicks + drivingDirections + GHL conversations marked GBP |
The Summary tab's source cards mix and match. Logic lives in SummarySection.tsx in the SOURCES.map(...) branch.
Data quality checks
- If Total Leads (sum of all buckets) ≠
opportunities.total, something is wrong with bucketing. - If google_ads bucket is non-zero but
googleAdsData.conversions.valueis zero, suspect either GHL tagging (false positive) or Google Ads conversion setup (false negative). - If other is the largest bucket, the client's contact source data is junk and the breakdown should be treated as illustrative rather than authoritative.