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.
The /api/metrics/ghl route
GET /api/metrics/ghl?location_id=XXX&client_id=XXX&start_date=YYYY-MM-DD&end_date=YYYY-MM-DDThis 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)
{
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:
| Field | Source timestamp |
|---|---|
| Lead created (Leads In) | opportunity.dateAdded |
| Won deal | opportunity.dateUpdated when status = won |
| Lost deal | opportunity.dateUpdated when status = lost |
| Appointment booked | calendar event.startTime |
| Appointment showed/cancelled | event.appointmentStatus + event.startTime |
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:
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 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).