Documentation
Data Mapping

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

BucketMatches 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)
otheranything not matching above

The bucketing function

Lives at the top of /api/metrics/ghl/route.ts:

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'
}
When to add a new bucket
If 'other' consistently exceeds 15% for a client, audit their 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:

BucketOverride source
google_adsGoogle Ads conversions count (preferred over GHL contact source where googleAdsData is connected)
social_media (paid)Meta Ads leads action count
gbpGBP 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.value is 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.