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 Name | Purpose | Value Set |
|---|---|---|
linkedin_url | Prospect's LinkedIn URL | From contact_linkedin |
job_title | Contact's job title | From contact_title |
market_name | Campaign/market name | From campaign.name |
cadence_stage | Calling cadence stage | Initialized to "active" |
dial_attempts | Number of dial attempts | Initialized to 0 |
no_answer_count | No-answer count | Initialized to 0 |
voicemail_count | Voicemail count | Initialized to 0 |
callback_attempts | Callback attempt count | Initialized 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:
| Column | Value |
|---|---|
last_synced_at | Current timestamp |
last_sync_status | success (all synced) or partial (some failed) |
last_sync_error | Concatenated 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:
| Column | Type | Description |
|---|---|---|
id | UUID | Batch ID |
ghl_integration_id | UUID | FK to integration |
location_id | TEXT | GHL location |
status | TEXT | pulling, pulled, reviewing, approved |
total_contacts_pulled | INT | Count of staged contacts |
total_calls_pulled | INT | Count of staged calls |
ghl_import_staging -- individual contact records:
| Column | Type | Description |
|---|---|---|
id | UUID | Row ID |
batch_id | UUID | FK to batch |
ghl_contact_id | TEXT | GHL contact ID |
ghl_location_id | TEXT | GHL location |
first_name | TEXT | Contact first name |
last_name | TEXT | Contact last name |
company_name | TEXT | Company name |
email | TEXT | Email address |
phone | TEXT | Raw phone |
phone_normalized | TEXT | Last 10 digits |
tags | JSONB | GHL tags array |
source | TEXT | GHL source field |
ghl_campaign | TEXT | Market Name custom field |
custom_fields | JSONB | All GHL custom fields |
calls_data | JSONB | Array of call message objects |
call_count | INT | Number of calls found |
is_duplicate | BOOLEAN | Matches existing record |
duplicate_of_table | TEXT | sales_prospects or contacts |
duplicate_of_id | UUID | ID 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:
- Phone matching: Normalizes phone to last 10 digits, checks against
sales_prospects.contact_phoneandcontacts.phone. - Email matching: Case-insensitive check against
sales_prospects.contact_emailandcontacts.email. - Result: Sets
is_duplicate,duplicate_of_table, andduplicate_of_idon 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.
Related Documentation
- GHL Overview -- Architecture and API configuration
- GHL Webhooks -- Webhook processing and disposition inference