Documentation
Integrations

GoHighLevel (GHL)

Pipeline data is the heart of the dashboard. GHL is where leads, appointments, and deals live.

Why GHL is special

GHL is the only source that drives the Pipeline Overview tab, and Pipeline is the agency's most-watched view. Almost every cost-per-X and rate calculation is downstream of GHL data, so a bug here cascades.

Authentication

We use an agency-level API key: GHL_API_KEY in env. It can read every sub-account (location) attached to our GHL agency. Each client maps to one location via clients.ghl_location_id.

Future migration
GHL is deprecating API keys in favour of OAuth 2.0. We will need to migrate to per-location OAuth installs once the deprecation date is firm. Until then, the agency key works.

The /api/metrics/ghl route

text
GET /api/metrics/ghl?location_id=XXX&client_id=XXX&start_date=YYYY-MM-DD&end_date=YYYY-MM-DD

This is the most complex route in the codebase. It fetches:

  • Opportunities — every opportunity in every pipeline of the location.
  • Contacts — for lead-source attribution.
  • Conversations — for inbound/outbound conversation counts.
  • Calendar events — for appointments booked / showed / cancelled.

It then filters everything to the selected date range and returns one big payload covering every pipeline KPI the dashboard needs.

Response shape (simplified)

ts
{
  metrics: {
    contacts:            { value, formatted },
    opportunities:       { total, open, won, lost, formatted, change },
    appointmentsBooked:  { value, formatted, change },
    appointmentsCancelled: { value, formatted, change },
    appointmentsShowed:  { value, formatted, change },
    pipelineValue:       { value, formatted, change },  // total $ of won deals
    wonDeals:            { value, formatted, change },
  },
  stageBreakdown: { 'Stage Name': count, ... },
  leadsBySource: {
    google_ads, gbp, organic_seo, social_media, direct, direct_referral, other
  },
  appointmentsBySource: { ... },
  salesBySource: { ... },
  leadBreakdown: { webforms, webchats, phoneCallsTotal, qualifiedCalls, uniqueFirstTimeCalls },
  weeklyTrend: [{ week, leads, booked, showed, cancelled, won }, ...]
}

Date filtering — the gotcha

GHL's opportunities endpoint does not accept a date range filter. We must fetch all opportunities and filter client-side by the relevant timestamp:

FieldSource timestamp
Lead created (Leads In)opportunity.dateAdded
Won dealopportunity.dateUpdated when status = won
Lost dealopportunity.dateUpdated when status = lost
Appointment bookedcalendar event.startTime
Appointment showed/cancelledevent.appointmentStatus + event.startTime
The April fix
Until April 2026, the route counted every opportunity ever created and only used the date range to compute a vs-previous-period delta. The fix: every counter must be incremented inside an if (inRange(ts)) guard. If you change this logic, run the regression test of clicking 7D / 30D / 90D and watching the numbers change.

Weekly trend (chart data)

weeklyTrend is intentionally kept all-time, not date-range filtered. The Pipeline Rates chart shows the last 12 weeks regardless of the global date range — it is meant to display a longer historical pattern.

Lead source attribution

Each contact in GHL has a source string. We bucket those into our standard categories. Mapping logic 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'))) 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')) return 'social_media'
  if (s.includes('referral')) return 'direct_referral'
  if (s.includes('direct')) return 'direct'
  return 'other'
}
Source consistency matters
Clients with messy source values in GHL get inaccurate attribution. Encourage account managers to standardise contact sources at intake (UTM parameters on web forms, consistent labels on offline calls).

Status

live All the above is wired and verified against Denver. Next on the list:

  • Per-stage cycle time (how long a lead stays in each stage).
  • Outbound contact attempt counters (currently null in the UI).
  • Walk-ins (manual data, currently null).