Skip to main content

GHL Prospect Sync

Client Portal synchronizes data with GoHighLevel in both directions: pushing prospects out for calling and pulling contacts/calls back for analysis.

Outbound: Push Prospects to GHL

Edge Function: sync-prospects-to-ghl

Trigger: Manual (called from the UI when a user clicks "Sync to GHL" on a campaign).

Input:

{
"campaign_id": "uuid-of-sales-campaign"
}

Process:

Custom Field Mapping

GHL uses opaque field IDs for custom fields. The ghl_integrations.custom_field_mapping JSONB column maps logical names to GHL IDs:

Logical NamePurposeValue Set
linkedin_urlProspect's LinkedIn URLFrom contact_linkedin
job_titleContact's job titleFrom contact_title
market_nameCampaign/market nameFrom campaign.name
cadence_stageCalling cadence stageInitialized to "active"
dial_attemptsNumber of dial attemptsInitialized to 0
no_answer_countNo-answer countInitialized to 0
voicemail_countVoicemail countInitialized to 0
callback_attemptsCallback attempt countInitialized to 0

GHL Contact Payload

const ghlBody = {
firstName: prospect.contact_first_name,
lastName: prospect.contact_last_name,
locationId: ghlInt.location_id,
companyName: prospect.firm_name,
source: "Portal Sync",
email: prospect.contact_email, // if present
phone: prospect.contact_phone, // if present
tags: [...prospect.tags, campaign.name],
customFields: [
{ id: fields.linkedin_url, value: prospect.contact_linkedin },
{ id: fields.job_title, value: prospect.contact_title },
{ id: fields.market_name, value: campaign.name },
{ id: fields.cadence_stage, value: "active" },
{ id: fields.dial_attempts, value: 0 },
// ...
],
};

Sync Status Tracking

After sync completes, the ghl_integrations row is updated:

ColumnValue
last_synced_atCurrent timestamp
last_sync_statussuccess (all synced) or partial (some failed)
last_sync_errorConcatenated error messages (max 2000 chars)

Rate Limiting

Each GHL API call is followed by a 200ms delay to avoid rate limiting:

await new Promise((r) => setTimeout(r, 200));

Inbound: Pull from GHL

Edge Function: pull-from-ghl

Purpose: Batch import contacts and their call history from a GHL location into a staging table for review before approval.

Input:

{
"ghl_integration_id": "uuid",
"batch_id": "uuid (optional, for resume)",
"start_after_id": "string (pagination cursor)",
"start_after": 1234567890,
"phase": "contacts | calls"
}

Two-Phase Import

The import runs in two phases, each designed to stay under the 60-second edge function timeout:

Timeout handling: The function checks elapsed time after each contact and stops at 50 seconds (10 seconds before the 60-second edge function timeout). The caller retries with the returned cursor.

Staging Tables

ghl_import_batches -- tracks each import run:

ColumnTypeDescription
idUUIDBatch ID
ghl_integration_idUUIDFK to integration
location_idTEXTGHL location
statusTEXTpulling, pulled, reviewing, approved
total_contacts_pulledINTCount of staged contacts
total_calls_pulledINTCount of staged calls

ghl_import_staging -- individual contact records:

ColumnTypeDescription
idUUIDRow ID
batch_idUUIDFK to batch
ghl_contact_idTEXTGHL contact ID
ghl_location_idTEXTGHL location
first_nameTEXTContact first name
last_nameTEXTContact last name
company_nameTEXTCompany name
emailTEXTEmail address
phoneTEXTRaw phone
phone_normalizedTEXTLast 10 digits
tagsJSONBGHL tags array
sourceTEXTGHL source field
ghl_campaignTEXTMarket Name custom field
custom_fieldsJSONBAll GHL custom fields
calls_dataJSONBArray of call message objects
call_countINTNumber of calls found
is_duplicateBOOLEANMatches existing record
duplicate_of_tableTEXTsales_prospects or contacts
duplicate_of_idUUIDID of matching record

Unique constraint: (batch_id, ghl_contact_id) -- prevents duplicate staging rows.

Market Name Extraction

The handler extracts the market name from GHL custom fields using a known field ID, with fallbacks:

// Priority: custom field -> campaign -> attributionSource
let ghlMarketName = null;
if (marketNameFieldId && contact.customFields) {
const match = contact.customFields.find((cf) => cf.id === marketNameFieldId);
if (match) ghlMarketName = match.value;
}
if (!ghlMarketName) {
ghlMarketName = contact.campaign ||
contact.campaignName ||
contact.attributionSource?.campaign ||
null;
}

Duplicate Detection

After both phases complete, the handler runs duplicate detection against existing records:

  1. Phone matching: Normalizes phone to last 10 digits, checks against sales_prospects.contact_phone and contacts.phone.
  2. Email matching: Case-insensitive check against sales_prospects.contact_email and contacts.email.
  3. Result: Sets is_duplicate, duplicate_of_table, and duplicate_of_id on staging rows.

Import Pipeline UI Flow

CSV Upload / GHL Pull
|
v
ghl_import_staging (review in UI)
|-- Mark approved / rejected per row
|
v
Approved rows -> INSERT into sales_prospects
|-- Set ghl_contact_id, ghl_synced_at, ghl_location_id
|-- Assign to sales_campaign

Phone Normalization

All phone comparisons use normalized form (last 10 digits, non-digit characters stripped):

function normalizePhone(phone: string): string {
return phone.replace(/\D/g, "").slice(-10);
}

This handles variations like +1 (555) 123-4567, 5551234567, and 15551234567 as equivalent.